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