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::types::{CoverMedia, CoverMediaKind, Payload};
16
17use super::cli::{self, Cli, Commands};
18
19/// Run the CLI. Returns `Ok(())` on success.
20///
21/// # Errors
22/// Returns [`AppError`] on any service failure.
23pub fn run() -> Result<(), AppError> {
24    let cli = Cli::parse();
25    dispatch(cli)
26}
27
28/// Dispatch a parsed CLI to the appropriate handler.
29///
30/// # Errors
31/// Returns [`AppError`] on any service failure.
32pub fn dispatch(cli: Cli) -> Result<(), AppError> {
33    match cli.command {
34        Commands::Version => {
35            print_version();
36            Ok(())
37        }
38        Commands::Keygen(args) => cmd_keygen(&args),
39        Commands::Embed(args) => cmd_embed(&args),
40        Commands::Extract(args) => cmd_extract(&args),
41        Commands::EmbedDistributed(args) => cmd_embed_distributed(&args),
42        Commands::ExtractDistributed(args) => cmd_extract_distributed(&args),
43        Commands::Analyse(args) => cmd_analyse(&args),
44        Commands::Archive(args) => cmd_archive(&args),
45        Commands::Scrub(args) => cmd_scrub(&args),
46        Commands::DeadDrop(args) => cmd_dead_drop(&args),
47        Commands::TimeLock(args) => cmd_time_lock(&args),
48        Commands::Watermark(args) => cmd_watermark(&args),
49        Commands::Corpus(args) => cmd_corpus(&args),
50        Commands::Panic(args) => cmd_panic(&args),
51        Commands::Completions(args) => cmd_completions(&args),
52    }
53}
54
55fn print_version() {
56    let version = env!("CARGO_PKG_VERSION");
57    let sha = option_env!("VERGEN_GIT_SHA").unwrap_or("unknown");
58    println!("shadowforge {version} ({sha})");
59}
60
61// ─── Keygen ───────────────────────────────────────────────────────────────────
62
63fn cmd_keygen(args: &cli::KeygenArgs) -> Result<(), AppError> {
64    let dir = &args.output;
65    fs_create_dir_all(dir)?;
66
67    match args.algorithm {
68        cli::Algorithm::Kyber1024 => {
69            let enc = crate::adapters::crypto::MlKemEncryptor;
70            let kp = KeyGenService::generate_keypair(&enc)?;
71            fs_write(&dir.join("public.key"), kp.public_key.as_ref())?;
72            fs_write(&dir.join("secret.key"), kp.secret_key.as_ref())?;
73        }
74        cli::Algorithm::Dilithium3 => {
75            let signer = crate::adapters::crypto::MlDsaSigner;
76            let kp = KeyGenService::generate_signing_keypair(&signer)?;
77            fs_write(&dir.join("public.key"), kp.public_key.as_ref())?;
78            fs_write(&dir.join("secret.key"), kp.secret_key.as_ref())?;
79        }
80    }
81    eprintln!("Keys written to {}", dir.display());
82    Ok(())
83}
84
85// ─── Embed ────────────────────────────────────────────────────────────────────
86
87fn cmd_embed(args: &cli::EmbedArgs) -> Result<(), AppError> {
88    let technique = resolve_technique(args.technique);
89    let embedder = build_embedder(technique);
90
91    let payload_bytes = fs_read(&args.input)?;
92    let mut payload = Payload::from_bytes(payload_bytes);
93
94    if args.scrub_style {
95        let scrubber = crate::adapters::scrubber::StyloScrubberImpl::new();
96        let profile = crate::domain::types::StyloProfile {
97            normalize_punctuation: true,
98            target_avg_sentence_len: 15.0,
99            target_vocab_size: 1000,
100        };
101        let text = String::from_utf8_lossy(payload.as_bytes());
102        let result = ScrubService::scrub(&text, &profile, &scrubber)?;
103        payload = Payload::from_bytes(result.into_bytes());
104    }
105
106    if args.amnesia {
107        let pipeline = crate::adapters::opsec::AmnesiaPipelineImpl::new();
108        let mut payload_cursor = io::Cursor::new(payload.as_bytes().to_vec());
109        let mut cover_input = io::stdin().lock();
110        let mut output = io::stdout().lock();
111        crate::application::services::AmnesiaPipelineService::embed_in_memory(
112            &mut payload_cursor,
113            &mut cover_input,
114            &mut output,
115            embedder.as_ref(),
116            &pipeline,
117        )?;
118    } else if args.deniable {
119        let cover_path = args.cover.as_ref().ok_or_else(|| {
120            crate::domain::errors::DeniableError::EmbedFailed {
121                reason: "--cover is required for deniable embedding".into(),
122            }
123        })?;
124        let cover_bytes = fs_read(cover_path)?;
125        let cover = load_cover(technique, &cover_bytes);
126
127        let decoy_path = args.decoy_payload.as_ref().ok_or_else(|| {
128            crate::domain::errors::DeniableError::EmbedFailed {
129                reason: "--decoy-payload is required for deniable embedding".into(),
130            }
131        })?;
132        let decoy_bytes = fs_read(decoy_path)?;
133        let primary_key = match &args.key {
134            Some(p) => fs_read(p)?,
135            None => vec![0u8; 32],
136        };
137        let decoy_key = match &args.decoy_key {
138            Some(p) => fs_read(p)?,
139            None => vec![1u8; 32],
140        };
141        let pair = crate::domain::types::DeniablePayloadPair {
142            real_payload: payload.as_bytes().to_vec(),
143            decoy_payload: decoy_bytes,
144        };
145        let keys = crate::domain::types::DeniableKeySet {
146            primary_key,
147            decoy_key,
148        };
149        let deniable = crate::adapters::stego::DualPayloadEmbedder;
150        let stego = crate::application::services::DeniableEmbedService::embed_dual(
151            cover,
152            &pair,
153            &keys,
154            embedder.as_ref(),
155            &deniable,
156        )?;
157        fs_write(&args.output, stego.data.as_ref())?;
158        eprintln!("Deniable embedding complete");
159    } else {
160        let cover_path = args.cover.as_ref().ok_or_else(|| {
161            crate::domain::errors::StegoError::MalformedCoverData {
162                reason: "--cover is required when not using --amnesia".into(),
163            }
164        })?;
165        let cover_bytes = fs_read(cover_path)?;
166        let cover = load_cover(technique, &cover_bytes);
167        let stego = EmbedService::embed(cover, &payload, embedder.as_ref())?;
168        fs_write(&args.output, stego.data.as_ref())?;
169        eprintln!("Embedded {} bytes", payload.len());
170    }
171    Ok(())
172}
173
174// ─── Extract ──────────────────────────────────────────────────────────────────
175
176fn cmd_extract(args: &cli::ExtractArgs) -> Result<(), AppError> {
177    let technique = resolve_technique(args.technique);
178    let extractor = build_extractor(technique);
179
180    if args.amnesia {
181        let mut buf = Vec::new();
182        io::stdin()
183            .lock()
184            .take(MAX_STDIN_PAYLOAD)
185            .read_to_end(&mut buf)
186            .map_err(|e| crate::domain::errors::StegoError::MalformedCoverData {
187                reason: format!("stdin read: {e}"),
188            })?;
189        let cover = load_cover(technique, &buf);
190        let payload = ExtractService::extract(&cover, extractor.as_ref())?;
191        io::stdout().write_all(payload.as_bytes()).map_err(|e| {
192            crate::domain::errors::StegoError::MalformedCoverData {
193                reason: format!("stdout write: {e}"),
194            }
195        })?;
196    } else {
197        let stego_bytes = fs_read(&args.input)?;
198        let stego = load_cover(technique, &stego_bytes);
199        let payload = ExtractService::extract(&stego, extractor.as_ref())?;
200        fs_write(&args.output, payload.as_bytes())?;
201        eprintln!("Extracted {} bytes", payload.len());
202    }
203    Ok(())
204}
205
206// ─── Embed-distributed ────────────────────────────────────────────────────────
207
208fn cmd_embed_distributed(args: &cli::EmbedDistributedArgs) -> Result<(), AppError> {
209    let technique = resolve_technique(args.technique);
210    let embedder = build_embedder(technique);
211
212    let payload_bytes = fs_read(&args.input)?;
213    let payload = Payload::from_bytes(payload_bytes);
214
215    let cover_paths = collect_cover_paths(&args.covers)?;
216    let covers: Result<Vec<CoverMedia>, AppError> = cover_paths
217        .iter()
218        .map(|p| {
219            let data = fs_read(p)?;
220            Ok(load_cover(technique, &data))
221        })
222        .collect();
223    let covers = covers?;
224
225    let profile = resolve_profile(args.profile, args.platform);
226    let hmac_key = match &args.hmac_key {
227        Some(p) => fs_read(p)?,
228        None => crate::adapters::distribution::DistributorImpl::generate_hmac_key(),
229    };
230    let distributor = crate::adapters::distribution::DistributorImpl::new(hmac_key.clone());
231
232    let stego_covers = crate::application::services::DistributeService::distribute(
233        &payload,
234        covers,
235        &profile,
236        &distributor,
237        embedder.as_ref(),
238    )?;
239
240    let files: Vec<(String, Vec<u8>)> = stego_covers
241        .iter()
242        .enumerate()
243        .map(|(i, c)| (format!("shard_{i:04}.png"), c.data.to_vec()))
244        .collect();
245    let file_refs: Vec<(&str, &[u8])> = files
246        .iter()
247        .map(|(n, d)| (n.as_str(), d.as_slice()))
248        .collect();
249    let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
250    let archive = ArchiveService::pack(
251        &file_refs,
252        crate::domain::types::ArchiveFormat::Zip,
253        &handler,
254    )?;
255    fs_write(&args.output_archive, &archive)?;
256    // Persist HMAC key so extract-distributed can use it
257    if args.hmac_key.is_none() {
258        let key_path = args.output_archive.with_extension("hmac");
259        fs_write(&key_path, &hmac_key)?;
260        eprintln!("HMAC key written to {}", key_path.display());
261    }
262    eprintln!(
263        "Distributed into {} shards → {}",
264        stego_covers.len(),
265        args.output_archive.display()
266    );
267    Ok(())
268}
269
270// ─── Extract-distributed ──────────────────────────────────────────────────────
271
272fn cmd_extract_distributed(args: &cli::ExtractDistributedArgs) -> Result<(), AppError> {
273    let technique = resolve_technique(args.technique);
274    let extractor = build_extractor(technique);
275
276    let archive_bytes = fs_read(&args.input_archive)?;
277    let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
278    let entries = ArchiveService::unpack(
279        &archive_bytes,
280        crate::domain::types::ArchiveFormat::Zip,
281        &handler,
282    )?;
283
284    let covers: Vec<CoverMedia> = entries
285        .iter()
286        .map(|(_, data)| load_cover(technique, data))
287        .collect();
288
289    let hmac_key = if let Some(p) = &args.hmac_key {
290        fs_read(p)?
291    } else {
292        // Try the default location next to the archive
293        let default_path = args.input_archive.with_extension("hmac");
294        fs_read(&default_path)?
295    };
296    let reconstructor = crate::adapters::reconstruction::ReconstructorImpl::new(
297        args.data_shards,
298        args.parity_shards,
299        hmac_key,
300        0,
301    );
302    let payload = crate::application::services::ReconstructService::reconstruct(
303        covers,
304        extractor.as_ref(),
305        &reconstructor,
306        &|done, total| eprintln!("Reconstructing: {done}/{total}"),
307    )?;
308    fs_write(&args.output, payload.as_bytes())?;
309    eprintln!("Reconstructed {} bytes", payload.len());
310    Ok(())
311}
312
313// ─── Analyse ──────────────────────────────────────────────────────────────────
314
315fn cmd_analyse(args: &cli::AnalyseArgs) -> Result<(), AppError> {
316    let technique = resolve_technique(args.technique);
317    let cover_bytes = fs_read(&args.cover)?;
318    let cover = load_cover(technique, &cover_bytes);
319    let analyser = crate::adapters::analysis::CapacityAnalyserImpl::new();
320    let report = AnalyseService::analyse(&cover, technique, &analyser)?;
321
322    if args.json {
323        let json = serde_json::to_string_pretty(&report).map_err(|e| {
324            crate::domain::errors::AnalysisError::ComputationFailed {
325                reason: format!("json serialisation: {e}"),
326            }
327        })?;
328        println!("{json}");
329    } else {
330        println!("Technique:     {:?}", report.technique);
331        println!("Capacity:      {} bytes", report.cover_capacity.bytes);
332        println!("Chi-square:    {:.2} dB", report.chi_square_score);
333        println!("Risk:          {:?}", report.detectability_risk);
334        println!(
335            "Recommended:   {} bytes",
336            report.recommended_max_payload_bytes
337        );
338    }
339    Ok(())
340}
341
342// ─── Archive ──────────────────────────────────────────────────────────────────
343
344fn cmd_archive(args: &cli::ArchiveArgs) -> Result<(), AppError> {
345    match &args.subcmd {
346        cli::ArchiveSubcommand::Pack {
347            files,
348            format,
349            output,
350        } => {
351            let file_data: Result<Vec<(String, Vec<u8>)>, AppError> = files
352                .iter()
353                .map(|p| {
354                    let name = p.file_name().map_or_else(
355                        || p.display().to_string(),
356                        |n| n.to_string_lossy().into_owned(),
357                    );
358                    let data = fs_read(p)?;
359                    Ok((name, data))
360                })
361                .collect();
362            let file_data = file_data?;
363            let refs: Vec<(&str, &[u8])> = file_data
364                .iter()
365                .map(|(n, d)| (n.as_str(), d.as_slice()))
366                .collect();
367            let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
368            let fmt = resolve_archive_format(*format);
369            let packed = ArchiveService::pack(&refs, fmt, &handler)?;
370            fs_write(output, &packed)?;
371            eprintln!("Packed {} files → {}", files.len(), output.display());
372        }
373        cli::ArchiveSubcommand::Unpack {
374            input,
375            format,
376            output_dir,
377        } => {
378            let data = fs_read(input)?;
379            let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
380            let fmt = resolve_archive_format(*format);
381            let entries = ArchiveService::unpack(&data, fmt, &handler)?;
382            fs_create_dir_all(output_dir)?;
383            for (name, content) in &entries {
384                let path = output_dir.join(name);
385                if let Some(parent) = path.parent() {
386                    fs_create_dir_all(parent)?;
387                }
388                fs_write(&path, content.as_ref())?;
389            }
390            eprintln!(
391                "Unpacked {} entries → {}",
392                entries.len(),
393                output_dir.display()
394            );
395        }
396    }
397    Ok(())
398}
399
400// ─── Scrub ────────────────────────────────────────────────────────────────────
401
402fn cmd_scrub(args: &cli::ScrubArgs) -> Result<(), AppError> {
403    let text = std::fs::read_to_string(&args.input).map_err(|e| {
404        crate::domain::errors::ScrubberError::InvalidUtf8 {
405            reason: format!("read: {e}"),
406        }
407    })?;
408    let scrubber = crate::adapters::scrubber::StyloScrubberImpl::new();
409    let profile = crate::domain::types::StyloProfile {
410        normalize_punctuation: true,
411        target_avg_sentence_len: f64::from(args.avg_sentence_len),
412        target_vocab_size: args.vocab_size,
413    };
414    let result = ScrubService::scrub(&text, &profile, &scrubber)?;
415    fs_write(&args.output, result.as_bytes())?;
416    eprintln!("Scrubbed text → {}", args.output.display());
417    Ok(())
418}
419
420// ─── Dead Drop ────────────────────────────────────────────────────────────────
421
422fn cmd_dead_drop(args: &cli::DeadDropArgs) -> Result<(), AppError> {
423    let technique = resolve_technique(args.technique);
424    let embedder = build_embedder(technique);
425    let cover_bytes = fs_read(&args.cover)?;
426    let cover = load_cover(technique, &cover_bytes);
427    let payload_bytes = fs_read(&args.input)?;
428    let payload = Payload::from_bytes(payload_bytes);
429    let platform = resolve_platform(args.platform);
430    let encoder = crate::adapters::deadrop::DeadDropEncoderImpl::new();
431    let stego = crate::application::services::DeadDropService::encode(
432        cover,
433        &payload,
434        &platform,
435        embedder.as_ref(),
436        &encoder,
437    )?;
438    fs_write(&args.output, stego.data.as_ref())?;
439    eprintln!("Dead drop encoded for {platform:?}");
440    Ok(())
441}
442
443// ─── Time Lock ────────────────────────────────────────────────────────────────
444
445fn cmd_time_lock(args: &cli::TimeLockArgs) -> Result<(), AppError> {
446    let service = crate::adapters::timelock::TimeLockServiceImpl::default();
447    match &args.subcmd {
448        cli::TimeLockSubcommand::Lock {
449            input,
450            unlock_at,
451            output_puzzle,
452        } => {
453            let data = fs_read(input)?;
454            let payload = Payload::from_bytes(data);
455            let ts = chrono::DateTime::parse_from_rfc3339(unlock_at)
456                .map_err(
457                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
458                        reason: format!("invalid RFC 3339 timestamp: {e}"),
459                    },
460                )?
461                .with_timezone(&chrono::Utc);
462            let puzzle =
463                crate::application::services::TimeLockServiceApp::lock(&payload, ts, &service)?;
464            let encoded = serde_json::to_vec_pretty(&puzzle).map_err(|e| {
465                crate::domain::errors::TimeLockError::ComputationFailed {
466                    reason: format!("serialize puzzle: {e}"),
467                }
468            })?;
469            fs_write(output_puzzle, &encoded)?;
470            eprintln!("Puzzle → {}", output_puzzle.display());
471        }
472        cli::TimeLockSubcommand::Unlock {
473            puzzle: puzzle_path,
474            output,
475        } => {
476            let data = fs_read(puzzle_path)?;
477            let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
478                .map_err(
479                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
480                        reason: format!("deserialize puzzle: {e}"),
481                    },
482                )?;
483            let payload =
484                crate::application::services::TimeLockServiceApp::unlock(&puzzle, &service)?;
485            fs_write(output, payload.as_bytes())?;
486            eprintln!("Unlocked {} bytes", payload.len());
487        }
488        cli::TimeLockSubcommand::TryUnlock {
489            puzzle: puzzle_path,
490        } => {
491            let data = fs_read(puzzle_path)?;
492            let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
493                .map_err(
494                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
495                        reason: format!("deserialize puzzle: {e}"),
496                    },
497                )?;
498            match crate::application::services::TimeLockServiceApp::try_unlock(&puzzle, &service)? {
499                Some(p) => eprintln!("Puzzle solved: {} bytes", p.len()),
500                None => eprintln!("Puzzle not yet solvable"),
501            }
502        }
503    }
504    Ok(())
505}
506
507// ─── Watermark ────────────────────────────────────────────────────────────────
508
509fn cmd_watermark(args: &cli::WatermarkArgs) -> Result<(), AppError> {
510    match &args.subcmd {
511        cli::WatermarkSubcommand::EmbedTripwire {
512            cover,
513            output,
514            recipient_id,
515        } => {
516            let cover_bytes = fs_read(cover)?;
517            let cover_media =
518                load_cover(crate::domain::types::StegoTechnique::LsbImage, &cover_bytes);
519            let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
520            let rid = uuid::Uuid::parse_str(recipient_id).map_err(|e| {
521                crate::domain::errors::OpsecError::WatermarkError {
522                    reason: format!("invalid UUID: {e}"),
523                }
524            })?;
525            let tag = crate::domain::types::WatermarkTripwireTag {
526                recipient_id: rid,
527                embedding_seed: recipient_id.as_bytes().to_vec(),
528            };
529            let stego = crate::application::services::ForensicService::embed_tripwire(
530                cover_media,
531                &tag,
532                &watermarker,
533            )?;
534            fs_write(output, stego.data.as_ref())?;
535            eprintln!("Tripwire embedded for {recipient_id}");
536        }
537        cli::WatermarkSubcommand::Identify { cover, tags } => {
538            let cover_bytes = fs_read(cover)?;
539            let cover_media =
540                load_cover(crate::domain::types::StegoTechnique::LsbImage, &cover_bytes);
541            let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
542            let tag_list = load_watermark_tags(tags)?;
543            let receipt = crate::application::services::ForensicService::identify_recipient(
544                &cover_media,
545                &tag_list,
546                &watermarker,
547            )?;
548            match receipt {
549                Some(r) => println!("Identified recipient: {r}"),
550                None => println!("No matching watermark found"),
551            }
552        }
553    }
554    Ok(())
555}
556
557// ─── Corpus ───────────────────────────────────────────────────────────────────
558
559fn cmd_corpus(args: &cli::CorpusArgs) -> Result<(), AppError> {
560    use crate::domain::ports::CorpusIndex;
561
562    let index = crate::adapters::corpus::CorpusIndexImpl::new();
563    match &args.subcmd {
564        cli::CorpusSubcommand::Build { dir } => {
565            let count = index.build_index(dir)?;
566            eprintln!("Indexed {count} images from {}", dir.display());
567        }
568        cli::CorpusSubcommand::Search {
569            input,
570            technique,
571            top,
572        } => {
573            let data = fs_read(input)?;
574            let payload = Payload::from_bytes(data);
575            let tech = resolve_technique(*technique);
576            let results = index.search(&payload, tech, *top)?;
577            for entry in &results {
578                println!("{}", entry.path);
579            }
580        }
581    }
582    Ok(())
583}
584
585// ─── Panic ────────────────────────────────────────────────────────────────────
586
587fn cmd_panic(args: &cli::PanicArgs) -> Result<(), AppError> {
588    let config = crate::domain::types::PanicWipeConfig {
589        key_paths: args.key_paths.iter().map(PathBuf::from).collect(),
590        config_paths: Vec::new(),
591        temp_dirs: Vec::new(),
592    };
593    let wiper = crate::adapters::opsec::SecurePanicWiper::new();
594    crate::application::services::PanicWipeService::wipe(&config, &wiper)?;
595    Ok(())
596}
597
598// ─── Completions ──────────────────────────────────────────────────────────────
599
600fn cmd_completions(args: &cli::CompletionsArgs) -> Result<(), AppError> {
601    use clap::CommandFactory;
602    use clap_complete::generate;
603
604    let mut cmd = Cli::command();
605    match &args.output {
606        Some(path) => {
607            let mut file = std::fs::File::create(path).map_err(|e| {
608                AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
609                    reason: format!("write {}: {e}", path.display()),
610                })
611            })?;
612            generate(args.shell, &mut cmd, "shadowforge", &mut file);
613        }
614        None => {
615            generate(args.shell, &mut cmd, "shadowforge", &mut io::stdout());
616        }
617    }
618    Ok(())
619}
620
621// ═══════════════════════════════════════════════════════════════════════════════
622// Helpers
623// ═══════════════════════════════════════════════════════════════════════════════
624
625fn fs_read(path: &Path) -> Result<Vec<u8>, AppError> {
626    std::fs::read(path).map_err(|e| {
627        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
628            reason: format!("read {}: {e}", path.display()),
629        })
630    })
631}
632
633fn fs_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
634    if let Some(parent) = path.parent() {
635        fs_create_dir_all(parent)?;
636    }
637    std::fs::write(path, data).map_err(|e| {
638        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
639            reason: format!("write {}: {e}", path.display()),
640        })
641    })
642}
643
644fn fs_create_dir_all(path: &Path) -> Result<(), AppError> {
645    std::fs::create_dir_all(path).map_err(|e| {
646        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
647            reason: format!("mkdir {}: {e}", path.display()),
648        })
649    })
650}
651
652fn load_cover(technique: crate::domain::types::StegoTechnique, data: &[u8]) -> CoverMedia {
653    let kind = match technique {
654        crate::domain::types::StegoTechnique::LsbAudio
655        | crate::domain::types::StegoTechnique::PhaseEncoding
656        | crate::domain::types::StegoTechnique::EchoHiding => CoverMediaKind::WavAudio,
657        crate::domain::types::StegoTechnique::ZeroWidthText => CoverMediaKind::PlainText,
658        crate::domain::types::StegoTechnique::PdfContentStream
659        | crate::domain::types::StegoTechnique::PdfMetadata => CoverMediaKind::PdfDocument,
660        crate::domain::types::StegoTechnique::LsbImage
661        | crate::domain::types::StegoTechnique::DctJpeg
662        | crate::domain::types::StegoTechnique::Palette
663        | crate::domain::types::StegoTechnique::CorpusSelection
664        | crate::domain::types::StegoTechnique::DualPayload => CoverMediaKind::PngImage,
665    };
666    CoverMedia {
667        kind,
668        data: bytes::Bytes::from(data.to_vec()),
669        metadata: std::collections::HashMap::new(),
670    }
671}
672
673/// Build an embedder for the given technique.
674fn build_embedder(
675    technique: crate::domain::types::StegoTechnique,
676) -> Box<dyn crate::domain::ports::EmbedTechnique> {
677    use crate::domain::types::StegoTechnique;
678    match technique {
679        StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
680        StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
681        StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
682        StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
683        StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
684        StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
685        // PDF/Corpus/Dual fall back to LSB — PDF needs PdfProcessor not available here
686        StegoTechnique::LsbImage
687        | StegoTechnique::PdfContentStream
688        | StegoTechnique::PdfMetadata
689        | StegoTechnique::CorpusSelection
690        | StegoTechnique::DualPayload => Box::new(crate::adapters::stego::LsbImage::new()),
691    }
692}
693
694/// Build an extractor for the given technique.
695fn build_extractor(
696    technique: crate::domain::types::StegoTechnique,
697) -> Box<dyn crate::domain::ports::ExtractTechnique> {
698    use crate::domain::types::StegoTechnique;
699    match technique {
700        StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
701        StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
702        StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
703        StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
704        StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
705        StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
706        StegoTechnique::LsbImage
707        | StegoTechnique::PdfContentStream
708        | StegoTechnique::PdfMetadata
709        | StegoTechnique::CorpusSelection
710        | StegoTechnique::DualPayload => Box::new(crate::adapters::stego::LsbImage::new()),
711    }
712}
713
714const fn resolve_technique(t: cli::Technique) -> crate::domain::types::StegoTechnique {
715    match t {
716        cli::Technique::Lsb => crate::domain::types::StegoTechnique::LsbImage,
717        cli::Technique::Dct => crate::domain::types::StegoTechnique::DctJpeg,
718        cli::Technique::Palette => crate::domain::types::StegoTechnique::Palette,
719        cli::Technique::LsbAudio => crate::domain::types::StegoTechnique::LsbAudio,
720        cli::Technique::Phase => crate::domain::types::StegoTechnique::PhaseEncoding,
721        cli::Technique::Echo => crate::domain::types::StegoTechnique::EchoHiding,
722        cli::Technique::ZeroWidth => crate::domain::types::StegoTechnique::ZeroWidthText,
723        cli::Technique::PdfStream => crate::domain::types::StegoTechnique::PdfContentStream,
724        cli::Technique::PdfMeta => crate::domain::types::StegoTechnique::PdfMetadata,
725        cli::Technique::Corpus => crate::domain::types::StegoTechnique::CorpusSelection,
726    }
727}
728
729fn resolve_profile(
730    profile: cli::Profile,
731    platform: Option<cli::Platform>,
732) -> crate::domain::types::EmbeddingProfile {
733    match profile {
734        cli::Profile::Standard => crate::domain::types::EmbeddingProfile::Standard,
735        cli::Profile::Adaptive => crate::domain::types::EmbeddingProfile::Adaptive {
736            max_detectability_db: -12.0,
737        },
738        cli::Profile::Survivable => {
739            let p = platform.map_or(crate::domain::types::PlatformProfile::Instagram, |pl| {
740                resolve_platform(pl)
741            });
742            crate::domain::types::EmbeddingProfile::CompressionSurvivable { platform: p }
743        }
744    }
745}
746
747const fn resolve_platform(p: cli::Platform) -> crate::domain::types::PlatformProfile {
748    match p {
749        cli::Platform::Instagram => crate::domain::types::PlatformProfile::Instagram,
750        cli::Platform::Twitter => crate::domain::types::PlatformProfile::Twitter,
751        cli::Platform::Whatsapp => crate::domain::types::PlatformProfile::WhatsApp,
752        cli::Platform::Telegram => crate::domain::types::PlatformProfile::Telegram,
753        cli::Platform::Imgur => crate::domain::types::PlatformProfile::Imgur,
754    }
755}
756
757const fn resolve_archive_format(f: cli::ArchiveFormat) -> crate::domain::types::ArchiveFormat {
758    match f {
759        cli::ArchiveFormat::Zip => crate::domain::types::ArchiveFormat::Zip,
760        cli::ArchiveFormat::Tar => crate::domain::types::ArchiveFormat::Tar,
761        cli::ArchiveFormat::TarGz => crate::domain::types::ArchiveFormat::TarGz,
762    }
763}
764
765/// Collect cover file paths — if the argument is a glob pattern expand it,
766/// otherwise treat it as a directory and list its files.
767fn collect_cover_paths(pattern: &str) -> Result<Vec<PathBuf>, AppError> {
768    let path = PathBuf::from(pattern);
769    let paths: Vec<PathBuf> = if path.is_dir() {
770        std::fs::read_dir(&path)
771            .map_err(
772                |_| crate::domain::errors::DistributionError::InsufficientCovers {
773                    needed: 1,
774                    got: 0,
775                },
776            )?
777            .filter_map(Result::ok)
778            .map(|e| e.path())
779            .filter(|p| p.is_file())
780            .collect()
781    } else {
782        // Treat as a literal file list (single file)
783        vec![path]
784    };
785
786    if paths.is_empty() {
787        return Err(
788            crate::domain::errors::DistributionError::InsufficientCovers { needed: 1, got: 0 }
789                .into(),
790        );
791    }
792    Ok(paths)
793}
794
795fn load_watermark_tags(
796    dir: &Path,
797) -> Result<Vec<crate::domain::types::WatermarkTripwireTag>, AppError> {
798    let mut tags = Vec::new();
799    if dir.is_dir() {
800        let entries = std::fs::read_dir(dir).map_err(|e| {
801            crate::domain::errors::OpsecError::WatermarkError {
802                reason: format!("read dir: {e}"),
803            }
804        })?;
805        for entry in entries {
806            let entry = entry.map_err(|e: std::io::Error| {
807                crate::domain::errors::OpsecError::WatermarkError {
808                    reason: format!("dir entry: {e}"),
809                }
810            })?;
811            let path = entry.path();
812            if path.is_file() {
813                // Parse tag from file: content is a recipient UUID string, used as seed too
814                if let Ok(content) = std::fs::read_to_string(&path) {
815                    let id_str = content.trim();
816                    let rid = uuid::Uuid::parse_str(id_str).map_err(|e| {
817                        crate::domain::errors::OpsecError::WatermarkError {
818                            reason: format!("invalid UUID in {}: {e}", path.display()),
819                        }
820                    })?;
821                    tags.push(crate::domain::types::WatermarkTripwireTag {
822                        recipient_id: rid,
823                        embedding_seed: id_str.as_bytes().to_vec(),
824                    });
825                }
826            }
827        }
828    }
829    Ok(tags)
830}