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, EmbedService, ExtractService, KeyGenService,
13    ScrubService,
14};
15use crate::domain::errors::{CanaryError, OpsecError, StegoError};
16use crate::domain::ports::{EmbedTechnique, ExtractTechnique, GeographicDistributor, MediaLoader};
17use crate::domain::types::{CoverMedia, CoverMediaKind, Payload, 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    }
55}
56
57fn print_version() {
58    let version = env!("CARGO_PKG_VERSION");
59    let sha = option_env!("VERGEN_GIT_SHA").unwrap_or("unknown");
60    println!("shadowforge {version} ({sha})");
61}
62
63// ─── Keygen ───────────────────────────────────────────────────────────────────
64
65fn cmd_keygen(args: &cli::KeygenArgs) -> Result<(), AppError> {
66    let dir = &args.output;
67    fs_create_dir_all(dir)?;
68
69    match args.algorithm {
70        cli::Algorithm::Kyber1024 => {
71            let enc = crate::adapters::crypto::MlKemEncryptor;
72            let kp = KeyGenService::generate_keypair(&enc)?;
73            fs_write(&dir.join("public.key"), kp.public_key.as_ref())?;
74            fs_write(&dir.join("secret.key"), kp.secret_key.as_ref())?;
75        }
76        cli::Algorithm::Dilithium3 => {
77            let signer = crate::adapters::crypto::MlDsaSigner;
78            let kp = KeyGenService::generate_signing_keypair(&signer)?;
79            fs_write(&dir.join("public.key"), kp.public_key.as_ref())?;
80            fs_write(&dir.join("secret.key"), kp.secret_key.as_ref())?;
81        }
82    }
83    eprintln!("Keys written to {}", dir.display());
84    Ok(())
85}
86
87// ─── Embed ────────────────────────────────────────────────────────────────────
88
89fn cmd_embed(args: &cli::EmbedArgs) -> Result<(), AppError> {
90    let technique = resolve_technique(args.technique);
91    let embedder = build_embedder(technique);
92
93    let payload_bytes = fs_read(&args.input)?;
94    let mut payload = Payload::from_bytes(payload_bytes);
95
96    if args.scrub_style {
97        let scrubber = crate::adapters::scrubber::StyloScrubberImpl::new();
98        let profile = crate::domain::types::StyloProfile {
99            normalize_punctuation: true,
100            target_avg_sentence_len: 15.0,
101            target_vocab_size: 1000,
102        };
103        let text = String::from_utf8_lossy(payload.as_bytes());
104        let result = ScrubService::scrub(&text, &profile, &scrubber)?;
105        payload = Payload::from_bytes(result.into_bytes());
106    }
107
108    if args.amnesia {
109        let pipeline = crate::adapters::opsec::AmnesiaPipelineImpl::new();
110        let mut payload_cursor = io::Cursor::new(payload.as_bytes().to_vec());
111        let mut cover_input = io::stdin().lock();
112        let mut output = io::stdout().lock();
113        crate::application::services::AmnesiaPipelineService::embed_in_memory(
114            &mut payload_cursor,
115            &mut cover_input,
116            &mut output,
117            embedder.as_ref(),
118            &pipeline,
119        )?;
120    } else if args.deniable {
121        let cover_path = args.cover.as_ref().ok_or_else(|| {
122            crate::domain::errors::DeniableError::EmbedFailed {
123                reason: "--cover is required for deniable embedding".into(),
124            }
125        })?;
126        let cover = load_cover_from_path(cover_path, technique)?;
127
128        let decoy_path = args.decoy_payload.as_ref().ok_or_else(|| {
129            crate::domain::errors::DeniableError::EmbedFailed {
130                reason: "--decoy-payload is required for deniable embedding".into(),
131            }
132        })?;
133        let decoy_bytes = fs_read(decoy_path)?;
134        let primary_key = match &args.key {
135            Some(p) => fs_read(p)?,
136            None => vec![0u8; 32],
137        };
138        let decoy_key = match &args.decoy_key {
139            Some(p) => fs_read(p)?,
140            None => vec![1u8; 32],
141        };
142        let pair = crate::domain::types::DeniablePayloadPair {
143            real_payload: payload.as_bytes().to_vec(),
144            decoy_payload: decoy_bytes,
145        };
146        let keys = crate::domain::types::DeniableKeySet {
147            primary_key,
148            decoy_key,
149        };
150        let deniable = crate::adapters::stego::DualPayloadEmbedder;
151        let stego = crate::application::services::DeniableEmbedService::embed_dual(
152            cover,
153            &pair,
154            &keys,
155            embedder.as_ref(),
156            &deniable,
157        )?;
158        save_cover_to_path(&stego, &args.output)?;
159        eprintln!("Deniable embedding complete");
160    } else {
161        // Note: profile and platform only apply to distributed embedding.
162        // Single-cover embedding uses the technique directly without profile constraints.
163        if matches!(args.profile, cli::Profile::Standard) {
164            // Standard profile: silent
165        } else {
166            eprintln!(
167                "Note: --profile {:?} only applies to distributed embedding; \
168                 single-cover uses technique directly",
169                args.profile
170            );
171        }
172
173        let cover_path = args.cover.as_ref().ok_or_else(|| {
174            crate::domain::errors::StegoError::MalformedCoverData {
175                reason: "--cover is required when not using --amnesia".into(),
176            }
177        })?;
178        let cover = load_cover_from_path(cover_path, technique)?;
179        let stego = EmbedService::embed(cover, &payload, embedder.as_ref())?;
180        save_cover_to_path(&stego, &args.output)?;
181        eprintln!("Embedded {} bytes", payload.len());
182    }
183    Ok(())
184}
185
186// ─── Extract ──────────────────────────────────────────────────────────────────
187
188fn cmd_extract(args: &cli::ExtractArgs) -> Result<(), AppError> {
189    let technique = resolve_technique(args.technique);
190    let extractor = build_extractor(technique);
191    let deniable_key = match &args.key {
192        Some(path) => Some(fs_read(path)?),
193        None => None,
194    };
195
196    if args.amnesia {
197        let mut buf = Vec::new();
198        io::stdin()
199            .lock()
200            .take(MAX_STDIN_PAYLOAD)
201            .read_to_end(&mut buf)
202            .map_err(|e| crate::domain::errors::StegoError::MalformedCoverData {
203                reason: format!("stdin read: {e}"),
204            })?;
205        let cover = load_cover(technique, &buf);
206        let payload = if let Some(key) = deniable_key.as_deref() {
207            let deniable = crate::adapters::stego::DualPayloadEmbedder;
208            crate::application::services::DeniableEmbedService::extract_with_key(
209                &cover,
210                key,
211                extractor.as_ref(),
212                &deniable,
213            )?
214        } else {
215            ExtractService::extract(&cover, extractor.as_ref())?
216        };
217        io::stdout().write_all(payload.as_bytes()).map_err(|e| {
218            crate::domain::errors::StegoError::MalformedCoverData {
219                reason: format!("stdout write: {e}"),
220            }
221        })?;
222    } else {
223        let stego = load_cover_from_path(&args.input, technique)?;
224        let payload = if let Some(key) = deniable_key.as_deref() {
225            let deniable = crate::adapters::stego::DualPayloadEmbedder;
226            crate::application::services::DeniableEmbedService::extract_with_key(
227                &stego,
228                key,
229                extractor.as_ref(),
230                &deniable,
231            )?
232        } else {
233            ExtractService::extract(&stego, extractor.as_ref())?
234        };
235        fs_write(&args.output, payload.as_bytes())?;
236        eprintln!("Extracted {} bytes", payload.len());
237    }
238    Ok(())
239}
240
241// ─── Embed-distributed ────────────────────────────────────────────────────────
242
243fn cmd_embed_distributed(args: &cli::EmbedDistributedArgs) -> Result<(), AppError> {
244    let technique = resolve_technique(args.technique);
245    let embedder = build_embedder(technique);
246
247    let payload_bytes = fs_read(&args.input)?;
248    let payload = Payload::from_bytes(payload_bytes);
249
250    let cover_paths = collect_cover_paths(&args.covers)?;
251    let covers: Result<Vec<CoverMedia>, AppError> = cover_paths
252        .iter()
253        .map(|path| load_cover_from_path(path, technique))
254        .collect();
255    let covers = covers?;
256
257    let profile = resolve_profile(args.profile, args.platform);
258    let (mut stego_covers, generated_hmac_key) =
259        distribute_covers(args, &payload, covers, &profile, embedder.as_ref())?;
260
261    // Embed canary shard if requested
262    let canary_metadata = if args.canary {
263        let canary_impl = crate::adapters::canary::CanaryServiceImpl::new(64, 5);
264        let (covers_with_canary, shard) =
265            crate::application::services::CanaryShardService::embed_canary(
266                stego_covers,
267                embedder.as_ref(),
268                &canary_impl,
269            )?;
270        stego_covers = covers_with_canary;
271        Some(shard)
272    } else {
273        None
274    };
275
276    let files: Vec<(String, Vec<u8>)> = stego_covers
277        .iter()
278        .enumerate()
279        .map(|(i, cover)| {
280            Ok((
281                format!("shard_{i:04}.{}", cover_file_extension(cover.kind)),
282                serialise_cover_to_bytes(cover)?,
283            ))
284        })
285        .collect::<Result<_, AppError>>()?;
286    let file_refs: Vec<(&str, &[u8])> = files
287        .iter()
288        .map(|(n, d)| (n.as_str(), d.as_slice()))
289        .collect();
290    let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
291    let archive = ArchiveService::pack(
292        &file_refs,
293        crate::domain::types::ArchiveFormat::Zip,
294        &handler,
295    )?;
296    fs_write(&args.output_archive, &archive)?;
297
298    // Persist HMAC key so extract-distributed can use it
299    if let Some(hmac_key) = generated_hmac_key {
300        let key_path = args.output_archive.with_extension("hmac");
301        fs_write(&key_path, &hmac_key)?;
302        eprintln!("HMAC key written to {}", key_path.display());
303    }
304
305    // Persist canary metadata if embedded
306    if let Some(canary_shard) = canary_metadata {
307        let canary_path = args.output_archive.with_extension("canary");
308        let canary_json = serde_json::to_string_pretty(&canary_shard).map_err(|e| {
309            let stego_err = StegoError::MalformedCoverData {
310                reason: format!("Failed to serialize canary metadata: {e}"),
311            };
312            AppError::Canary(CanaryError::EmbedFailed { source: stego_err })
313        })?;
314        fs_write(&canary_path, canary_json.as_bytes())?;
315        eprintln!("Canary metadata written to {}", canary_path.display());
316    }
317
318    eprintln!(
319        "Distributed into {} shards → {}",
320        stego_covers.len(),
321        args.output_archive.display()
322    );
323    Ok(())
324}
325
326fn distribute_covers(
327    args: &cli::EmbedDistributedArgs,
328    payload: &Payload,
329    covers: Vec<CoverMedia>,
330    profile: &crate::domain::types::EmbeddingProfile,
331    embedder: &dyn EmbedTechnique,
332) -> Result<(Vec<CoverMedia>, Option<Vec<u8>>), AppError> {
333    if let Some(manifest_path) = &args.geo_manifest {
334        let manifest = load_geographic_manifest(manifest_path)?;
335        let geo_distributor = crate::adapters::opsec::GeographicDistributorImpl::new();
336        let stego_covers =
337            geo_distributor.distribute_with_manifest(payload, covers, &manifest, embedder)?;
338        return Ok((stego_covers, None));
339    }
340
341    let hmac_key = if let Some(p) = &args.hmac_key {
342        fs_read(p)?
343    } else {
344        crate::adapters::distribution::DistributorImpl::generate_hmac_key()
345    };
346    let generated_hmac_key = if args.hmac_key.is_none() {
347        Some(hmac_key.clone())
348    } else {
349        None
350    };
351    let distributor = crate::adapters::distribution::DistributorImpl::new_with_shard_config(
352        hmac_key,
353        args.data_shards,
354        args.parity_shards,
355    );
356    let stego_covers = crate::application::services::DistributeService::distribute(
357        payload,
358        covers,
359        profile,
360        &distributor,
361        embedder,
362    )?;
363    Ok((stego_covers, generated_hmac_key))
364}
365
366fn load_geographic_manifest(
367    manifest_path: &Path,
368) -> Result<crate::domain::types::GeographicManifest, AppError> {
369    let manifest_raw = fs_read(manifest_path)?;
370    let manifest_str = String::from_utf8(manifest_raw).map_err(|e| OpsecError::ManifestError {
371        reason: format!("manifest is not valid UTF-8: {e}"),
372    })?;
373    toml::from_str(&manifest_str).map_err(|e| {
374        OpsecError::ManifestError {
375            reason: format!("manifest parse failed: {e}"),
376        }
377        .into()
378    })
379}
380
381// ─── Extract-distributed ──────────────────────────────────────────────────────
382
383fn cmd_extract_distributed(args: &cli::ExtractDistributedArgs) -> Result<(), AppError> {
384    let technique = resolve_technique(args.technique);
385    let extractor = build_extractor(technique);
386
387    let archive_bytes = fs_read(&args.input_archive)?;
388    let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
389    let entries = ArchiveService::unpack(
390        &archive_bytes,
391        crate::domain::types::ArchiveFormat::Zip,
392        &handler,
393    )?;
394
395    let covers: Vec<CoverMedia> = entries
396        .iter()
397        .map(|(name, data)| load_cover_from_named_bytes(name, technique, data))
398        .collect::<Result<_, AppError>>()?;
399
400    let hmac_key = if let Some(p) = &args.hmac_key {
401        fs_read(p)?
402    } else {
403        // Try the default location next to the archive
404        let default_path = args.input_archive.with_extension("hmac");
405        fs_read(&default_path)?
406    };
407    let reconstructor = crate::adapters::reconstruction::ReconstructorImpl::new(
408        args.data_shards,
409        args.parity_shards,
410        hmac_key,
411        0,
412    );
413    let payload = crate::application::services::ReconstructService::reconstruct(
414        covers,
415        extractor.as_ref(),
416        &reconstructor,
417        &|done, total| eprintln!("Reconstructing: {done}/{total}"),
418    )?;
419    fs_write(&args.output, payload.as_bytes())?;
420    eprintln!("Reconstructed {} bytes", payload.len());
421    Ok(())
422}
423
424// ─── Analyse ──────────────────────────────────────────────────────────────────
425
426fn cmd_analyse(args: &cli::AnalyseArgs) -> Result<(), AppError> {
427    let technique = resolve_technique(args.technique);
428    let cover = load_cover_from_path(&args.cover, technique)?;
429    let analyser = crate::adapters::analysis::CapacityAnalyserImpl::new();
430    let report = AnalyseService::analyse(&cover, technique, &analyser)?;
431
432    if args.json {
433        let json = serde_json::to_string_pretty(&report).map_err(|e| {
434            crate::domain::errors::AnalysisError::ComputationFailed {
435                reason: format!("json serialisation: {e}"),
436            }
437        })?;
438        println!("{json}");
439    } else {
440        println!("Technique:     {:?}", report.technique);
441        println!("Capacity:      {} bytes", report.cover_capacity.bytes);
442        println!("Chi-square:    {:.2} dB", report.chi_square_score);
443        println!("Risk:          {:?}", report.detectability_risk);
444        println!(
445            "Recommended:   {} bytes",
446            report.recommended_max_payload_bytes
447        );
448    }
449    Ok(())
450}
451
452// ─── Archive ──────────────────────────────────────────────────────────────────
453
454fn cmd_archive(args: &cli::ArchiveArgs) -> Result<(), AppError> {
455    match &args.subcmd {
456        cli::ArchiveSubcommand::Pack {
457            files,
458            format,
459            output,
460        } => {
461            let file_data: Result<Vec<(String, Vec<u8>)>, AppError> = files
462                .iter()
463                .map(|p| {
464                    let name = p.file_name().map_or_else(
465                        || p.display().to_string(),
466                        |n| n.to_string_lossy().into_owned(),
467                    );
468                    let data = fs_read(p)?;
469                    Ok((name, data))
470                })
471                .collect();
472            let file_data = file_data?;
473            let refs: Vec<(&str, &[u8])> = file_data
474                .iter()
475                .map(|(n, d)| (n.as_str(), d.as_slice()))
476                .collect();
477            let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
478            let fmt = resolve_archive_format(*format);
479            let packed = ArchiveService::pack(&refs, fmt, &handler)?;
480            fs_write(output, &packed)?;
481            eprintln!("Packed {} files → {}", files.len(), output.display());
482        }
483        cli::ArchiveSubcommand::Unpack {
484            input,
485            format,
486            output_dir,
487        } => {
488            let data = fs_read(input)?;
489            let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
490            let fmt = resolve_archive_format(*format);
491            let entries = ArchiveService::unpack(&data, fmt, &handler)?;
492            fs_create_dir_all(output_dir)?;
493            for (name, content) in &entries {
494                let path = output_dir.join(name);
495                if let Some(parent) = path.parent() {
496                    fs_create_dir_all(parent)?;
497                }
498                fs_write(&path, content.as_ref())?;
499            }
500            eprintln!(
501                "Unpacked {} entries → {}",
502                entries.len(),
503                output_dir.display()
504            );
505        }
506    }
507    Ok(())
508}
509
510// ─── Scrub ────────────────────────────────────────────────────────────────────
511
512fn cmd_scrub(args: &cli::ScrubArgs) -> Result<(), AppError> {
513    let text = std::fs::read_to_string(&args.input).map_err(|e| {
514        crate::domain::errors::ScrubberError::InvalidUtf8 {
515            reason: format!("read: {e}"),
516        }
517    })?;
518    let scrubber = crate::adapters::scrubber::StyloScrubberImpl::new();
519    let profile = crate::domain::types::StyloProfile {
520        normalize_punctuation: true,
521        target_avg_sentence_len: f64::from(args.avg_sentence_len),
522        target_vocab_size: args.vocab_size,
523    };
524    let result = ScrubService::scrub(&text, &profile, &scrubber)?;
525    fs_write(&args.output, result.as_bytes())?;
526    eprintln!("Scrubbed text → {}", args.output.display());
527    Ok(())
528}
529
530// ─── Dead Drop ────────────────────────────────────────────────────────────────
531
532fn cmd_dead_drop(args: &cli::DeadDropArgs) -> Result<(), AppError> {
533    let technique = resolve_technique(args.technique);
534    let embedder = build_embedder(technique);
535    let cover = load_cover_from_path(&args.cover, technique)?;
536    let payload_bytes = fs_read(&args.input)?;
537    let payload = Payload::from_bytes(payload_bytes);
538    let platform = resolve_platform(args.platform);
539    let encoder = crate::adapters::deadrop::DeadDropEncoderImpl::new();
540    let stego = crate::application::services::DeadDropService::encode(
541        cover,
542        &payload,
543        &platform,
544        embedder.as_ref(),
545        &encoder,
546    )?;
547    save_cover_to_path(&stego, &args.output)?;
548    eprintln!("Dead drop encoded for {platform:?}");
549    Ok(())
550}
551
552// ─── Time Lock ────────────────────────────────────────────────────────────────
553
554fn cmd_time_lock(args: &cli::TimeLockArgs) -> Result<(), AppError> {
555    let service = crate::adapters::timelock::TimeLockServiceImpl::default();
556    match &args.subcmd {
557        cli::TimeLockSubcommand::Lock {
558            input,
559            unlock_at,
560            output_puzzle,
561        } => {
562            let data = fs_read(input)?;
563            let payload = Payload::from_bytes(data);
564            let ts = chrono::DateTime::parse_from_rfc3339(unlock_at)
565                .map_err(
566                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
567                        reason: format!("invalid RFC 3339 timestamp: {e}"),
568                    },
569                )?
570                .with_timezone(&chrono::Utc);
571            let puzzle =
572                crate::application::services::TimeLockServiceApp::lock(&payload, ts, &service)?;
573            let encoded = serde_json::to_vec_pretty(&puzzle).map_err(|e| {
574                crate::domain::errors::TimeLockError::ComputationFailed {
575                    reason: format!("serialize puzzle: {e}"),
576                }
577            })?;
578            fs_write(output_puzzle, &encoded)?;
579            eprintln!("Puzzle → {}", output_puzzle.display());
580        }
581        cli::TimeLockSubcommand::Unlock {
582            puzzle: puzzle_path,
583            output,
584        } => {
585            let data = fs_read(puzzle_path)?;
586            let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
587                .map_err(
588                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
589                        reason: format!("deserialize puzzle: {e}"),
590                    },
591                )?;
592            let payload =
593                crate::application::services::TimeLockServiceApp::unlock(&puzzle, &service)?;
594            fs_write(output, payload.as_bytes())?;
595            eprintln!("Unlocked {} bytes", payload.len());
596        }
597        cli::TimeLockSubcommand::TryUnlock {
598            puzzle: puzzle_path,
599        } => {
600            let data = fs_read(puzzle_path)?;
601            let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
602                .map_err(
603                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
604                        reason: format!("deserialize puzzle: {e}"),
605                    },
606                )?;
607            match crate::application::services::TimeLockServiceApp::try_unlock(&puzzle, &service)? {
608                Some(p) => eprintln!("Puzzle solved: {} bytes", p.len()),
609                None => eprintln!("Puzzle not yet solvable"),
610            }
611        }
612    }
613    Ok(())
614}
615
616// ─── Watermark ────────────────────────────────────────────────────────────────
617
618fn cmd_watermark(args: &cli::WatermarkArgs) -> Result<(), AppError> {
619    match &args.subcmd {
620        cli::WatermarkSubcommand::EmbedTripwire {
621            cover,
622            output,
623            recipient_id,
624        } => {
625            let cover_media =
626                load_cover_from_path(cover, crate::domain::types::StegoTechnique::LsbImage)?;
627            let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
628            let rid = uuid::Uuid::parse_str(recipient_id).map_err(|e| {
629                crate::domain::errors::OpsecError::WatermarkError {
630                    reason: format!("invalid UUID: {e}"),
631                }
632            })?;
633            let tag = crate::domain::types::WatermarkTripwireTag {
634                recipient_id: rid,
635                embedding_seed: recipient_id.as_bytes().to_vec(),
636            };
637            let stego = crate::application::services::ForensicService::embed_tripwire(
638                cover_media,
639                &tag,
640                &watermarker,
641            )?;
642            save_cover_to_path(&stego, output)?;
643            eprintln!("Tripwire embedded for {recipient_id}");
644        }
645        cli::WatermarkSubcommand::Identify { cover, tags } => {
646            let cover_media =
647                load_cover_from_path(cover, crate::domain::types::StegoTechnique::LsbImage)?;
648            let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
649            let tag_list = load_watermark_tags(tags)?;
650            let receipt = crate::application::services::ForensicService::identify_recipient(
651                &cover_media,
652                &tag_list,
653                &watermarker,
654            )?;
655            match receipt {
656                Some(r) => println!("Identified recipient: {r}"),
657                None => println!("No matching watermark found"),
658            }
659        }
660    }
661    Ok(())
662}
663
664// ─── Corpus ───────────────────────────────────────────────────────────────────
665
666fn cmd_corpus(args: &cli::CorpusArgs) -> Result<(), AppError> {
667    use crate::domain::ports::CorpusIndex;
668
669    let index = crate::adapters::corpus::CorpusIndexImpl::new();
670    match &args.subcmd {
671        cli::CorpusSubcommand::Build { dir } => {
672            let count = index.build_index(dir)?;
673            eprintln!("Indexed {count} images from {}", dir.display());
674        }
675        cli::CorpusSubcommand::Search {
676            input,
677            technique,
678            top,
679        } => {
680            let data = fs_read(input)?;
681            let payload = Payload::from_bytes(data);
682            let tech = resolve_technique(*technique);
683            let results = index.search(&payload, tech, *top)?;
684            for entry in &results {
685                println!("{}", entry.path);
686            }
687        }
688    }
689    Ok(())
690}
691
692// ─── Panic ────────────────────────────────────────────────────────────────────
693
694fn cmd_panic(args: &cli::PanicArgs) -> Result<(), AppError> {
695    let config = crate::domain::types::PanicWipeConfig {
696        key_paths: args.key_paths.iter().map(PathBuf::from).collect(),
697        config_paths: Vec::new(),
698        temp_dirs: Vec::new(),
699    };
700    let wiper = crate::adapters::opsec::SecurePanicWiper::new();
701    crate::application::services::PanicWipeService::wipe(&config, &wiper)?;
702    Ok(())
703}
704
705// ─── Completions ──────────────────────────────────────────────────────────────
706
707fn cmd_completions(args: &cli::CompletionsArgs) -> Result<(), AppError> {
708    use clap::CommandFactory;
709    use clap_complete::generate;
710
711    let mut cmd = Cli::command();
712    match &args.output {
713        Some(path) => {
714            let mut file = std::fs::File::create(path).map_err(|e| {
715                AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
716                    reason: format!("write {}: {e}", path.display()),
717                })
718            })?;
719            generate(args.shell, &mut cmd, "shadowforge", &mut file);
720        }
721        None => {
722            generate(args.shell, &mut cmd, "shadowforge", &mut io::stdout());
723        }
724    }
725    Ok(())
726}
727
728// ═══════════════════════════════════════════════════════════════════════════════
729// Helpers
730// ═══════════════════════════════════════════════════════════════════════════════
731
732fn fs_read(path: &Path) -> Result<Vec<u8>, AppError> {
733    std::fs::read(path).map_err(|e| {
734        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
735            reason: format!("read {}: {e}", path.display()),
736        })
737    })
738}
739
740fn fs_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
741    if let Some(parent) = path.parent() {
742        fs_create_dir_all(parent)?;
743    }
744    std::fs::write(path, data).map_err(|e| {
745        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
746            reason: format!("write {}: {e}", path.display()),
747        })
748    })
749}
750
751fn fs_create_dir_all(path: &Path) -> Result<(), AppError> {
752    std::fs::create_dir_all(path).map_err(|e| {
753        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
754            reason: format!("mkdir {}: {e}", path.display()),
755        })
756    })
757}
758
759fn map_media_error(error: crate::domain::errors::MediaError) -> AppError {
760    match error {
761        crate::domain::errors::MediaError::UnsupportedFormat { extension } => {
762            AppError::Stego(crate::domain::errors::StegoError::UnsupportedCoverType {
763                reason: format!("unsupported cover format: {extension}"),
764            })
765        }
766        crate::domain::errors::MediaError::DecodeFailed { reason }
767        | crate::domain::errors::MediaError::EncodeFailed { reason }
768        | crate::domain::errors::MediaError::IoError { reason } => {
769            AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData { reason })
770        }
771    }
772}
773
774#[cfg(feature = "pdf")]
775fn map_pdf_runner_error(error: crate::domain::errors::PdfError) -> AppError {
776    match error {
777        crate::domain::errors::PdfError::Encrypted => {
778            AppError::Stego(crate::domain::errors::StegoError::UnsupportedCoverType {
779                reason: "encrypted PDF documents are not supported".to_string(),
780            })
781        }
782        crate::domain::errors::PdfError::ParseFailed { reason }
783        | crate::domain::errors::PdfError::RenderFailed { reason, .. }
784        | crate::domain::errors::PdfError::RebuildFailed { reason }
785        | crate::domain::errors::PdfError::EmbedFailed { reason }
786        | crate::domain::errors::PdfError::ExtractFailed { reason }
787        | crate::domain::errors::PdfError::IoError { reason } => {
788            AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData { reason })
789        }
790    }
791}
792
793fn load_cover_from_path(path: &Path, technique: StegoTechnique) -> Result<CoverMedia, AppError> {
794    match technique {
795        StegoTechnique::LsbAudio | StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
796            let loader = crate::adapters::media::AudioMediaLoader;
797            loader.load(path).map_err(map_media_error)
798        }
799        StegoTechnique::PdfContentStream | StegoTechnique::PdfMetadata => load_pdf_cover(path),
800        StegoTechnique::ZeroWidthText => {
801            let data = fs_read(path)?;
802            Ok(load_cover(technique, &data))
803        }
804        StegoTechnique::LsbImage
805        | StegoTechnique::DctJpeg
806        | StegoTechnique::Palette
807        | StegoTechnique::CorpusSelection
808        | StegoTechnique::DualPayload => {
809            let loader = crate::adapters::media::ImageMediaLoader;
810            loader.load(path).map_err(map_media_error)
811        }
812    }
813}
814
815#[cfg(feature = "pdf")]
816fn load_pdf_cover(path: &Path) -> Result<CoverMedia, AppError> {
817    use crate::domain::ports::PdfProcessor;
818
819    let processor = crate::adapters::pdf::PdfProcessorImpl::default();
820    processor.load_pdf(path).map_err(map_pdf_runner_error)
821}
822
823#[cfg(not(feature = "pdf"))]
824fn load_pdf_cover(_path: &Path) -> Result<CoverMedia, AppError> {
825    Err(AppError::Stego(
826        crate::domain::errors::StegoError::UnsupportedCoverType {
827            reason: "PDF support is not enabled in this build".to_string(),
828        },
829    ))
830}
831
832fn save_cover_to_path(media: &CoverMedia, path: &Path) -> Result<(), AppError> {
833    if let Some(parent) = path.parent() {
834        fs_create_dir_all(parent)?;
835    }
836
837    match media.kind {
838        CoverMediaKind::PngImage
839        | CoverMediaKind::BmpImage
840        | CoverMediaKind::JpegImage
841        | CoverMediaKind::GifImage => {
842            let loader = crate::adapters::media::ImageMediaLoader;
843            loader.save(media, path).map_err(map_media_error)
844        }
845        CoverMediaKind::WavAudio => {
846            let loader = crate::adapters::media::AudioMediaLoader;
847            loader.save(media, path).map_err(map_media_error)
848        }
849        CoverMediaKind::PdfDocument => save_pdf_cover(media, path),
850        CoverMediaKind::PlainText => fs_write(path, media.data.as_ref()),
851    }
852}
853
854#[cfg(feature = "pdf")]
855fn save_pdf_cover(media: &CoverMedia, path: &Path) -> Result<(), AppError> {
856    use crate::domain::ports::PdfProcessor;
857
858    let processor = crate::adapters::pdf::PdfProcessorImpl::default();
859    processor
860        .save_pdf(media, path)
861        .map_err(map_pdf_runner_error)
862}
863
864#[cfg(not(feature = "pdf"))]
865fn save_pdf_cover(_media: &CoverMedia, _path: &Path) -> Result<(), AppError> {
866    Err(AppError::Stego(
867        crate::domain::errors::StegoError::UnsupportedCoverType {
868            reason: "PDF support is not enabled in this build".to_string(),
869        },
870    ))
871}
872
873fn serialise_cover_to_bytes(media: &CoverMedia) -> Result<Vec<u8>, AppError> {
874    match media.kind {
875        CoverMediaKind::PdfDocument | CoverMediaKind::PlainText => Ok(media.data.to_vec()),
876        _ => {
877            let temp_path = std::env::temp_dir().join(format!(
878                "shadowforge-{}.{}",
879                uuid::Uuid::new_v4(),
880                cover_file_extension(media.kind)
881            ));
882            let result = (|| {
883                save_cover_to_path(media, &temp_path)?;
884                fs_read(&temp_path)
885            })();
886            let _ = std::fs::remove_file(&temp_path);
887            result
888        }
889    }
890}
891
892fn load_cover_from_named_bytes(
893    name: &str,
894    technique: StegoTechnique,
895    data: &[u8],
896) -> Result<CoverMedia, AppError> {
897    if technique == StegoTechnique::ZeroWidthText {
898        return Ok(load_cover(technique, data));
899    }
900
901    let extension = Path::new(name)
902        .extension()
903        .and_then(|value| value.to_str())
904        .unwrap_or_else(|| technique_file_extension(technique));
905    let temp_path = std::env::temp_dir().join(format!(
906        "shadowforge-{}.{}",
907        uuid::Uuid::new_v4(),
908        extension
909    ));
910    let result = (|| {
911        fs_write(&temp_path, data)?;
912        load_cover_from_path(&temp_path, technique)
913    })();
914    let _ = std::fs::remove_file(&temp_path);
915    result
916}
917
918const fn cover_file_extension(kind: CoverMediaKind) -> &'static str {
919    match kind {
920        CoverMediaKind::PngImage => "png",
921        CoverMediaKind::BmpImage => "bmp",
922        CoverMediaKind::JpegImage => "jpg",
923        CoverMediaKind::GifImage => "gif",
924        CoverMediaKind::WavAudio => "wav",
925        CoverMediaKind::PdfDocument => "pdf",
926        CoverMediaKind::PlainText => "txt",
927    }
928}
929
930const fn technique_file_extension(technique: StegoTechnique) -> &'static str {
931    match technique {
932        StegoTechnique::LsbAudio | StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
933            "wav"
934        }
935        StegoTechnique::PdfContentStream | StegoTechnique::PdfMetadata => "pdf",
936        StegoTechnique::ZeroWidthText => "txt",
937        StegoTechnique::DctJpeg => "jpg",
938        StegoTechnique::LsbImage
939        | StegoTechnique::Palette
940        | StegoTechnique::CorpusSelection
941        | StegoTechnique::DualPayload => "png",
942    }
943}
944
945fn load_cover(technique: crate::domain::types::StegoTechnique, data: &[u8]) -> CoverMedia {
946    let kind = match technique {
947        crate::domain::types::StegoTechnique::LsbAudio
948        | crate::domain::types::StegoTechnique::PhaseEncoding
949        | crate::domain::types::StegoTechnique::EchoHiding => CoverMediaKind::WavAudio,
950        crate::domain::types::StegoTechnique::ZeroWidthText => CoverMediaKind::PlainText,
951        crate::domain::types::StegoTechnique::PdfContentStream
952        | crate::domain::types::StegoTechnique::PdfMetadata => CoverMediaKind::PdfDocument,
953        crate::domain::types::StegoTechnique::DctJpeg => CoverMediaKind::JpegImage,
954        crate::domain::types::StegoTechnique::LsbImage
955        | crate::domain::types::StegoTechnique::Palette
956        | crate::domain::types::StegoTechnique::CorpusSelection
957        | crate::domain::types::StegoTechnique::DualPayload => CoverMediaKind::PngImage,
958    };
959    CoverMedia {
960        kind,
961        data: bytes::Bytes::from(data.to_vec()),
962        metadata: std::collections::HashMap::new(),
963    }
964}
965
966/// Build an embedder for the given technique.
967fn build_embedder(technique: crate::domain::types::StegoTechnique) -> Box<dyn EmbedTechnique> {
968    use crate::domain::types::StegoTechnique;
969    match technique {
970        StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
971        StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
972        StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
973        StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
974        StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
975        StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
976        StegoTechnique::PdfContentStream => build_pdf_content_stream_embedder(),
977        StegoTechnique::PdfMetadata => build_pdf_metadata_embedder(),
978        StegoTechnique::CorpusSelection => Box::new(UnsupportedTechnique::new(
979            StegoTechnique::CorpusSelection,
980            "corpus selection must use the corpus workflow, not generic embed",
981        )),
982        StegoTechnique::DualPayload => Box::new(UnsupportedTechnique::new(
983            StegoTechnique::DualPayload,
984            "dual-payload embedding must use the deniable embed workflow",
985        )),
986        StegoTechnique::LsbImage => Box::new(crate::adapters::stego::LsbImage::new()),
987    }
988}
989
990/// Build an extractor for the given technique.
991fn build_extractor(technique: crate::domain::types::StegoTechnique) -> Box<dyn ExtractTechnique> {
992    use crate::domain::types::StegoTechnique;
993    match technique {
994        StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
995        StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
996        StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
997        StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
998        StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
999        StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
1000        StegoTechnique::PdfContentStream => build_pdf_content_stream_extractor(),
1001        StegoTechnique::PdfMetadata => build_pdf_metadata_extractor(),
1002        StegoTechnique::CorpusSelection => Box::new(UnsupportedTechnique::new(
1003            StegoTechnique::CorpusSelection,
1004            "corpus selection must use the corpus workflow, not generic extract",
1005        )),
1006        StegoTechnique::DualPayload => Box::new(UnsupportedTechnique::new(
1007            StegoTechnique::DualPayload,
1008            "dual-payload extraction must use the deniable extraction workflow",
1009        )),
1010        StegoTechnique::LsbImage => Box::new(crate::adapters::stego::LsbImage::new()),
1011    }
1012}
1013
1014#[cfg(feature = "pdf")]
1015fn build_pdf_content_stream_embedder() -> Box<dyn EmbedTechnique> {
1016    Box::new(crate::adapters::pdf::PdfContentStreamStego::new())
1017}
1018
1019#[cfg(not(feature = "pdf"))]
1020fn build_pdf_content_stream_embedder() -> Box<dyn EmbedTechnique> {
1021    Box::new(UnsupportedTechnique::new(
1022        crate::domain::types::StegoTechnique::PdfContentStream,
1023        "PDF support is not enabled in this build",
1024    ))
1025}
1026
1027#[cfg(feature = "pdf")]
1028fn build_pdf_metadata_embedder() -> Box<dyn EmbedTechnique> {
1029    Box::new(crate::adapters::pdf::PdfMetadataStego::new())
1030}
1031
1032#[cfg(not(feature = "pdf"))]
1033fn build_pdf_metadata_embedder() -> Box<dyn EmbedTechnique> {
1034    Box::new(UnsupportedTechnique::new(
1035        crate::domain::types::StegoTechnique::PdfMetadata,
1036        "PDF support is not enabled in this build",
1037    ))
1038}
1039
1040#[cfg(feature = "pdf")]
1041fn build_pdf_content_stream_extractor() -> Box<dyn ExtractTechnique> {
1042    Box::new(crate::adapters::pdf::PdfContentStreamStego::new())
1043}
1044
1045#[cfg(not(feature = "pdf"))]
1046fn build_pdf_content_stream_extractor() -> Box<dyn ExtractTechnique> {
1047    Box::new(UnsupportedTechnique::new(
1048        crate::domain::types::StegoTechnique::PdfContentStream,
1049        "PDF support is not enabled in this build",
1050    ))
1051}
1052
1053#[cfg(feature = "pdf")]
1054fn build_pdf_metadata_extractor() -> Box<dyn ExtractTechnique> {
1055    Box::new(crate::adapters::pdf::PdfMetadataStego::new())
1056}
1057
1058#[cfg(not(feature = "pdf"))]
1059fn build_pdf_metadata_extractor() -> Box<dyn ExtractTechnique> {
1060    Box::new(UnsupportedTechnique::new(
1061        crate::domain::types::StegoTechnique::PdfMetadata,
1062        "PDF support is not enabled in this build",
1063    ))
1064}
1065
1066#[derive(Debug)]
1067struct UnsupportedTechnique {
1068    technique: crate::domain::types::StegoTechnique,
1069    reason: &'static str,
1070}
1071
1072impl UnsupportedTechnique {
1073    const fn new(technique: crate::domain::types::StegoTechnique, reason: &'static str) -> Self {
1074        Self { technique, reason }
1075    }
1076}
1077
1078impl EmbedTechnique for UnsupportedTechnique {
1079    fn technique(&self) -> crate::domain::types::StegoTechnique {
1080        self.technique
1081    }
1082
1083    fn capacity(&self, _cover: &CoverMedia) -> Result<crate::domain::types::Capacity, StegoError> {
1084        Err(StegoError::UnsupportedCoverType {
1085            reason: self.reason.to_string(),
1086        })
1087    }
1088
1089    fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
1090        Err(StegoError::UnsupportedCoverType {
1091            reason: self.reason.to_string(),
1092        })
1093    }
1094}
1095
1096impl ExtractTechnique for UnsupportedTechnique {
1097    fn technique(&self) -> crate::domain::types::StegoTechnique {
1098        self.technique
1099    }
1100
1101    fn extract(&self, _stego: &CoverMedia) -> Result<Payload, StegoError> {
1102        Err(StegoError::UnsupportedCoverType {
1103            reason: self.reason.to_string(),
1104        })
1105    }
1106}
1107
1108const fn resolve_technique(t: cli::Technique) -> crate::domain::types::StegoTechnique {
1109    match t {
1110        cli::Technique::Lsb => crate::domain::types::StegoTechnique::LsbImage,
1111        cli::Technique::Dct => crate::domain::types::StegoTechnique::DctJpeg,
1112        cli::Technique::Palette => crate::domain::types::StegoTechnique::Palette,
1113        cli::Technique::LsbAudio => crate::domain::types::StegoTechnique::LsbAudio,
1114        cli::Technique::Phase => crate::domain::types::StegoTechnique::PhaseEncoding,
1115        cli::Technique::Echo => crate::domain::types::StegoTechnique::EchoHiding,
1116        cli::Technique::ZeroWidth => crate::domain::types::StegoTechnique::ZeroWidthText,
1117        cli::Technique::PdfStream => crate::domain::types::StegoTechnique::PdfContentStream,
1118        cli::Technique::PdfMeta => crate::domain::types::StegoTechnique::PdfMetadata,
1119        cli::Technique::Corpus => crate::domain::types::StegoTechnique::CorpusSelection,
1120    }
1121}
1122
1123fn resolve_profile(
1124    profile: cli::Profile,
1125    platform: Option<cli::Platform>,
1126) -> crate::domain::types::EmbeddingProfile {
1127    match profile {
1128        cli::Profile::Standard => crate::domain::types::EmbeddingProfile::Standard,
1129        cli::Profile::Adaptive => crate::domain::types::EmbeddingProfile::Adaptive {
1130            max_detectability_db: -12.0,
1131        },
1132        cli::Profile::Survivable => {
1133            let p = platform.map_or(crate::domain::types::PlatformProfile::Instagram, |pl| {
1134                resolve_platform(pl)
1135            });
1136            crate::domain::types::EmbeddingProfile::CompressionSurvivable { platform: p }
1137        }
1138    }
1139}
1140
1141const fn resolve_platform(p: cli::Platform) -> crate::domain::types::PlatformProfile {
1142    match p {
1143        cli::Platform::Instagram => crate::domain::types::PlatformProfile::Instagram,
1144        cli::Platform::Twitter => crate::domain::types::PlatformProfile::Twitter,
1145        cli::Platform::Whatsapp => crate::domain::types::PlatformProfile::WhatsApp,
1146        cli::Platform::Telegram => crate::domain::types::PlatformProfile::Telegram,
1147        cli::Platform::Imgur => crate::domain::types::PlatformProfile::Imgur,
1148    }
1149}
1150
1151const fn resolve_archive_format(f: cli::ArchiveFormat) -> crate::domain::types::ArchiveFormat {
1152    match f {
1153        cli::ArchiveFormat::Zip => crate::domain::types::ArchiveFormat::Zip,
1154        cli::ArchiveFormat::Tar => crate::domain::types::ArchiveFormat::Tar,
1155        cli::ArchiveFormat::TarGz => crate::domain::types::ArchiveFormat::TarGz,
1156    }
1157}
1158
1159/// Collect cover file paths — if the argument is a glob pattern expand it,
1160/// otherwise treat it as a directory and list its files.
1161fn collect_cover_paths(pattern: &str) -> Result<Vec<PathBuf>, AppError> {
1162    let path = PathBuf::from(pattern);
1163    let paths: Vec<PathBuf> = if path.is_dir() {
1164        std::fs::read_dir(&path)
1165            .map_err(
1166                |_| crate::domain::errors::DistributionError::InsufficientCovers {
1167                    needed: 1,
1168                    got: 0,
1169                },
1170            )?
1171            .filter_map(Result::ok)
1172            .map(|e| e.path())
1173            .filter(|p| p.is_file())
1174            .collect()
1175    } else if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
1176        glob::glob(pattern)
1177            .map_err(
1178                |_| crate::domain::errors::DistributionError::InsufficientCovers {
1179                    needed: 1,
1180                    got: 0,
1181                },
1182            )?
1183            .filter_map(Result::ok)
1184            .filter(|p| p.is_file())
1185            .collect()
1186    } else {
1187        // Treat as a literal file list (single file)
1188        vec![path]
1189    };
1190
1191    if paths.is_empty() {
1192        return Err(
1193            crate::domain::errors::DistributionError::InsufficientCovers { needed: 1, got: 0 }
1194                .into(),
1195        );
1196    }
1197    Ok(paths)
1198}
1199
1200fn load_watermark_tags(
1201    dir: &Path,
1202) -> Result<Vec<crate::domain::types::WatermarkTripwireTag>, AppError> {
1203    let mut tags = Vec::new();
1204    if dir.is_dir() {
1205        let entries = std::fs::read_dir(dir).map_err(|e| {
1206            crate::domain::errors::OpsecError::WatermarkError {
1207                reason: format!("read dir: {e}"),
1208            }
1209        })?;
1210        for entry in entries {
1211            let entry = entry.map_err(|e: std::io::Error| {
1212                crate::domain::errors::OpsecError::WatermarkError {
1213                    reason: format!("dir entry: {e}"),
1214                }
1215            })?;
1216            let path = entry.path();
1217            if path.is_file() {
1218                // Parse tag from file: content is a recipient UUID string, used as seed too
1219                if let Ok(content) = std::fs::read_to_string(&path) {
1220                    let id_str = content.trim();
1221                    let rid = uuid::Uuid::parse_str(id_str).map_err(|e| {
1222                        crate::domain::errors::OpsecError::WatermarkError {
1223                            reason: format!("invalid UUID in {}: {e}", path.display()),
1224                        }
1225                    })?;
1226                    tags.push(crate::domain::types::WatermarkTripwireTag {
1227                        recipient_id: rid,
1228                        embedding_seed: id_str.as_bytes().to_vec(),
1229                    });
1230                }
1231            }
1232        }
1233    }
1234    Ok(tags)
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::{
1240        build_embedder, build_extractor, cmd_extract, load_cover, load_cover_from_path,
1241        save_cover_to_path,
1242    };
1243    use crate::application::services::DeniableEmbedService;
1244    use crate::domain::types::{CoverMediaKind, StegoTechnique};
1245    use crate::interface::cli;
1246    use image::{DynamicImage, Rgba, RgbaImage};
1247    use std::fs;
1248    use tempfile::tempdir;
1249
1250    type TestResult = Result<(), Box<dyn std::error::Error>>;
1251
1252    #[test]
1253    fn dct_cover_is_classified_as_jpeg() {
1254        let cover = load_cover(StegoTechnique::DctJpeg, b"jpeg");
1255        assert_eq!(cover.kind, CoverMediaKind::JpegImage);
1256    }
1257
1258    #[test]
1259    fn pdf_cover_is_classified_as_pdf() {
1260        let cover = load_cover(StegoTechnique::PdfContentStream, b"%PDF-1.7");
1261        assert_eq!(cover.kind, CoverMediaKind::PdfDocument);
1262    }
1263
1264    #[test]
1265    fn pdf_content_stream_embedder_uses_pdf_technique() {
1266        let embedder = build_embedder(StegoTechnique::PdfContentStream);
1267        assert_eq!(embedder.technique(), StegoTechnique::PdfContentStream);
1268    }
1269
1270    #[test]
1271    fn pdf_metadata_extractor_uses_pdf_technique() {
1272        let extractor = build_extractor(StegoTechnique::PdfMetadata);
1273        assert_eq!(extractor.technique(), StegoTechnique::PdfMetadata);
1274    }
1275
1276    #[test]
1277    fn corpus_embedder_is_not_rewritten_as_lsb_image() {
1278        let embedder = build_embedder(StegoTechnique::CorpusSelection);
1279        assert_eq!(embedder.technique(), StegoTechnique::CorpusSelection);
1280    }
1281
1282    #[test]
1283    fn file_backed_image_covers_use_media_loader() -> TestResult {
1284        let dir = tempdir()?;
1285        let input_path = dir.path().join("cover.png");
1286        let output_path = dir.path().join("roundtrip.png");
1287
1288        let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(3, 2, Rgba([1, 2, 3, 255])));
1289        image.save(&input_path)?;
1290
1291        let cover = load_cover_from_path(&input_path, StegoTechnique::LsbImage)?;
1292        assert_eq!(cover.kind, CoverMediaKind::PngImage);
1293        assert_eq!(cover.metadata.get("width"), Some(&"3".to_string()));
1294        assert_eq!(cover.metadata.get("height"), Some(&"2".to_string()));
1295
1296        save_cover_to_path(&cover, &output_path)?;
1297        let written = fs::read(output_path)?;
1298        assert!(!written.is_empty());
1299        Ok(())
1300    }
1301
1302    #[test]
1303    fn extract_uses_deniable_key_path_when_provided() -> TestResult {
1304        let dir = tempdir()?;
1305        let cover_path = dir.path().join("cover.png");
1306        let input_path = dir.path().join("input.png");
1307        let output_path = dir.path().join("output.bin");
1308        let key_path = dir.path().join("primary.key");
1309
1310        let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(40, 40, Rgba([0, 0, 0, 255])));
1311        image.save(&cover_path)?;
1312
1313        let primary_key = vec![7u8; 32];
1314        let decoy_key = vec![9u8; 32];
1315        let pair = crate::domain::types::DeniablePayloadPair {
1316            real_payload: b"real secret".to_vec(),
1317            decoy_payload: b"decoy".to_vec(),
1318        };
1319        let keys = crate::domain::types::DeniableKeySet {
1320            primary_key: primary_key.clone(),
1321            decoy_key,
1322        };
1323        let cover = load_cover_from_path(&cover_path, StegoTechnique::LsbImage)?;
1324        let deniable = crate::adapters::stego::DualPayloadEmbedder;
1325        let embedder = build_embedder(StegoTechnique::LsbImage);
1326        let stego =
1327            DeniableEmbedService::embed_dual(cover, &pair, &keys, embedder.as_ref(), &deniable)?;
1328
1329        save_cover_to_path(&stego, &input_path)?;
1330        fs::write(&key_path, &primary_key)?;
1331
1332        let args = cli::ExtractArgs {
1333            input: input_path,
1334            output: output_path.clone(),
1335            technique: cli::Technique::Lsb,
1336            key: Some(key_path),
1337            amnesia: false,
1338        };
1339
1340        cmd_extract(&args)?;
1341
1342        let extracted = fs::read(output_path)?;
1343        assert_eq!(extracted, b"real secret");
1344        Ok(())
1345    }
1346}