1use std::io::{self, Read, Write};
4use std::path::{Path, PathBuf};
5
6const 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
19pub fn run() -> Result<(), AppError> {
24 let cli = Cli::parse();
25 dispatch(cli)
26}
27
28pub 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
61fn 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
85fn 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
174fn 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
206fn 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 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
270fn 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 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
313fn 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
342fn 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
400fn 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
420fn 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
443fn 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
507fn 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
557fn 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
585fn 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
598fn 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
621fn 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
673fn 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 StegoTechnique::LsbImage
687 | StegoTechnique::PdfContentStream
688 | StegoTechnique::PdfMetadata
689 | StegoTechnique::CorpusSelection
690 | StegoTechnique::DualPayload => Box::new(crate::adapters::stego::LsbImage::new()),
691 }
692}
693
694fn 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
765fn 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 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 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}