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> = Box::new(
481 crate::adapters::correction::RsErrorCorrector::new(hmac_key.clone()),
482 );
483 let reconstructor = crate::adapters::reconstruction::ReconstructorImpl::new(
484 args.data_shards,
485 args.parity_shards,
486 0,
487 corrector_for_recon,
488 );
489 let payload = crate::application::services::ReconstructService::reconstruct(
490 covers,
491 extractor.as_ref(),
492 &reconstructor,
493 &|done, total| eprintln!("Reconstructing: {done}/{total}"),
494 )?;
495 fs_write(&args.output, payload.as_bytes())?;
496 eprintln!("Reconstructed {} bytes", payload.len());
497 Ok(())
498}
499
500pub(crate) fn format_analysis_report(report: &crate::domain::types::AnalysisReport) -> String {
505 use std::fmt::Write as _;
506 let mut out = String::new();
507 let _ = writeln!(out, "Technique: {:?}", report.technique);
508 let _ = writeln!(out, "Capacity: {} bytes", report.cover_capacity.bytes);
509 let _ = writeln!(out, "Chi-square: {:.2} dB", report.chi_square_score);
510 let _ = writeln!(out, "Risk: {:?}", report.detectability_risk);
511 let _ = writeln!(
512 out,
513 "Recommended: {} bytes",
514 report.recommended_max_payload_bytes
515 );
516 if let Some(ai) = &report.ai_watermark {
517 let _ = writeln!(out, "--- AI Watermark Detection ---");
518 let _ = writeln!(
519 out,
520 " Detected: {}",
521 if ai.detected { "yes" } else { "no" }
522 );
523 if let Some(model_id) = &ai.model_id {
524 let _ = writeln!(out, " Model: {model_id}");
525 }
526 if ai.total_strong_bins > 0 {
527 let _ = writeln!(out, " Confidence: {:.4}", ai.confidence);
528 let _ = writeln!(
529 out,
530 " Matched strong bins: {}/{}",
531 ai.matched_strong_bins, ai.total_strong_bins
532 );
533 } else {
534 let _ = writeln!(out, " Status: no known profile match");
535 }
536 }
537 if let Some(s) = &report.spectral_score {
538 let _ = writeln!(out, "--- Spectral Detectability ---");
539 let _ = writeln!(out, " Phase coherence drop: {:.4}", s.phase_coherence_drop);
540 let _ = writeln!(
541 out,
542 " Carrier SNR drop: {:.2} dB",
543 s.carrier_snr_drop_db
544 );
545 let _ = writeln!(
546 out,
547 " Sample-pair asymmetry:{:.4}",
548 s.sample_pair_asymmetry
549 );
550 let _ = writeln!(out, " Spectral risk: {:?}", s.combined_risk);
551 }
552 out
553}
554
555fn cmd_analyse(args: &cli::AnalyseArgs) -> Result<(), AppError> {
556 let technique = resolve_technique(args.technique);
557 let cover = load_cover_from_path(&args.cover, technique)?;
558 let analyser = crate::adapters::analysis::CapacityAnalyserImpl::new();
559 let report = AnalyseService::analyse(&cover, technique, &analyser)?;
560
561 if args.json {
562 let json = serde_json::to_string_pretty(&report).map_err(|e| {
563 crate::domain::errors::AnalysisError::ComputationFailed {
564 reason: format!("json serialisation: {e}"),
565 }
566 })?;
567 println!("{json}");
568 } else {
569 print!("{}", format_analysis_report(&report));
570 }
571 Ok(())
572}
573
574fn cmd_archive(args: &cli::ArchiveArgs) -> Result<(), AppError> {
577 match &args.subcmd {
578 cli::ArchiveSubcommand::Pack {
579 files,
580 format,
581 output,
582 } => {
583 let file_data: Result<Vec<(String, Vec<u8>)>, AppError> = files
584 .iter()
585 .map(|p| {
586 let name = p.file_name().map_or_else(
587 || p.display().to_string(),
588 |n| n.to_string_lossy().into_owned(),
589 );
590 let data = fs_read(p)?;
591 Ok((name, data))
592 })
593 .collect();
594 let file_data = file_data?;
595 let refs: Vec<(&str, &[u8])> = file_data
596 .iter()
597 .map(|(n, d)| (n.as_str(), d.as_slice()))
598 .collect();
599 let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
600 let fmt = resolve_archive_format(*format);
601 let packed = ArchiveService::pack(&refs, fmt, &handler)?;
602 fs_write(output, &packed)?;
603 eprintln!("Packed {} files → {}", files.len(), output.display());
604 }
605 cli::ArchiveSubcommand::Unpack {
606 input,
607 format,
608 output_dir,
609 } => {
610 let data = fs_read(input)?;
611 let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
612 let fmt = resolve_archive_format(*format);
613 let entries = ArchiveService::unpack(&data, fmt, &handler)?;
614 fs_create_dir_all(output_dir)?;
615 for (name, content) in &entries {
616 let path = output_dir.join(name);
617 if let Some(parent) = path.parent() {
618 fs_create_dir_all(parent)?;
619 }
620 fs_write(&path, content.as_ref())?;
621 }
622 eprintln!(
623 "Unpacked {} entries → {}",
624 entries.len(),
625 output_dir.display()
626 );
627 }
628 }
629 Ok(())
630}
631
632fn cmd_scrub(args: &cli::ScrubArgs) -> Result<(), AppError> {
635 let text = std::fs::read_to_string(&args.input).map_err(|e| {
636 crate::domain::errors::ScrubberError::InvalidUtf8 {
637 reason: format!("read: {e}"),
638 }
639 })?;
640 let scrubber = crate::adapters::scrubber::StyloScrubberImpl::new();
641 let profile = crate::domain::types::StyloProfile {
642 normalize_punctuation: true,
643 target_avg_sentence_len: f64::from(args.avg_sentence_len),
644 target_vocab_size: args.vocab_size,
645 };
646 let result = ScrubService::scrub(&text, &profile, &scrubber)?;
647 fs_write(&args.output, result.as_bytes())?;
648 eprintln!("Scrubbed text → {}", args.output.display());
649 Ok(())
650}
651
652fn cmd_dead_drop(args: &cli::DeadDropArgs) -> Result<(), AppError> {
655 let technique = resolve_technique(args.technique);
656 let embedder = build_embedder(technique);
657 let cover = load_cover_from_path(&args.cover, technique)?;
658 let payload_bytes = fs_read(&args.input)?;
659 let payload = Payload::from_bytes(payload_bytes);
660 let platform = resolve_platform(args.platform);
661 let encoder = crate::adapters::deadrop::DeadDropEncoderImpl::new();
662 let stego = crate::application::services::DeadDropService::encode(
663 cover,
664 &payload,
665 &platform,
666 embedder.as_ref(),
667 &encoder,
668 )?;
669 save_cover_to_path(&stego, &args.output)?;
670 eprintln!("Dead drop encoded for {platform:?}");
671 Ok(())
672}
673
674fn cmd_time_lock(args: &cli::TimeLockArgs) -> Result<(), AppError> {
677 let service = crate::adapters::timelock::TimeLockServiceImpl::default();
678 match &args.subcmd {
679 cli::TimeLockSubcommand::Lock {
680 input,
681 unlock_at,
682 output_puzzle,
683 } => {
684 let data = fs_read(input)?;
685 let payload = Payload::from_bytes(data);
686 let ts = chrono::DateTime::parse_from_rfc3339(unlock_at)
687 .map_err(
688 |e| crate::domain::errors::TimeLockError::ComputationFailed {
689 reason: format!("invalid RFC 3339 timestamp: {e}"),
690 },
691 )?
692 .with_timezone(&chrono::Utc);
693 let puzzle =
694 crate::application::services::TimeLockServiceApp::lock(&payload, ts, &service)?;
695 let encoded = serde_json::to_vec_pretty(&puzzle).map_err(|e| {
696 crate::domain::errors::TimeLockError::ComputationFailed {
697 reason: format!("serialize puzzle: {e}"),
698 }
699 })?;
700 fs_write(output_puzzle, &encoded)?;
701 eprintln!("Puzzle → {}", output_puzzle.display());
702 }
703 cli::TimeLockSubcommand::Unlock {
704 puzzle: puzzle_path,
705 output,
706 } => {
707 let data = fs_read(puzzle_path)?;
708 let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
709 .map_err(
710 |e| crate::domain::errors::TimeLockError::ComputationFailed {
711 reason: format!("deserialize puzzle: {e}"),
712 },
713 )?;
714 let payload =
715 crate::application::services::TimeLockServiceApp::unlock(&puzzle, &service)?;
716 fs_write(output, payload.as_bytes())?;
717 eprintln!("Unlocked {} bytes", payload.len());
718 }
719 cli::TimeLockSubcommand::TryUnlock {
720 puzzle: puzzle_path,
721 } => {
722 let data = fs_read(puzzle_path)?;
723 let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
724 .map_err(
725 |e| crate::domain::errors::TimeLockError::ComputationFailed {
726 reason: format!("deserialize puzzle: {e}"),
727 },
728 )?;
729 match crate::application::services::TimeLockServiceApp::try_unlock(&puzzle, &service)? {
730 Some(p) => eprintln!("Puzzle solved: {} bytes", p.len()),
731 None => eprintln!("Puzzle not yet solvable"),
732 }
733 }
734 }
735 Ok(())
736}
737
738fn cmd_watermark(args: &cli::WatermarkArgs) -> Result<(), AppError> {
741 match &args.subcmd {
742 cli::WatermarkSubcommand::EmbedTripwire {
743 cover,
744 output,
745 recipient_id,
746 } => {
747 let cover_media =
748 load_cover_from_path(cover, crate::domain::types::StegoTechnique::LsbImage)?;
749 let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
750 let rid = uuid::Uuid::parse_str(recipient_id).map_err(|e| {
751 crate::domain::errors::OpsecError::WatermarkError {
752 reason: format!("invalid UUID: {e}"),
753 }
754 })?;
755 let tag = crate::domain::types::WatermarkTripwireTag {
756 recipient_id: rid,
757 embedding_seed: recipient_id.as_bytes().to_vec(),
758 };
759 let stego = crate::application::services::ForensicService::embed_tripwire(
760 cover_media,
761 &tag,
762 &watermarker,
763 )?;
764 save_cover_to_path(&stego, output)?;
765 eprintln!("Tripwire embedded for {recipient_id}");
766 }
767 cli::WatermarkSubcommand::Identify { cover, tags } => {
768 let cover_media =
769 load_cover_from_path(cover, crate::domain::types::StegoTechnique::LsbImage)?;
770 let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
771 let tag_list = load_watermark_tags(tags)?;
772 let receipt = crate::application::services::ForensicService::identify_recipient(
773 &cover_media,
774 &tag_list,
775 &watermarker,
776 )?;
777 match receipt {
778 Some(r) => println!("Identified recipient: {r}"),
779 None => println!("No matching watermark found"),
780 }
781 }
782 }
783 Ok(())
784}
785
786fn cmd_corpus(args: &cli::CorpusArgs) -> Result<(), AppError> {
789 let index = crate::adapters::corpus::CorpusIndexImpl::new();
790 match &args.subcmd {
791 cli::CorpusSubcommand::Build { dir } => {
792 let count = CorpusService::build_index(&index, dir)?;
793 eprintln!("Indexed {count} images from {}", dir.display());
794 }
795 cli::CorpusSubcommand::Search {
796 input,
797 technique,
798 top,
799 model,
800 resolution,
801 } => {
802 let data = fs_read(input)?;
803 let payload = Payload::from_bytes(data);
804 let tech = resolve_technique(*technique);
805
806 let results = if let Some(model_id) = model {
810 let res = resolution
811 .as_deref()
812 .and_then(|s| {
813 let mut parts = s.splitn(2, 'x');
814 let w = parts.next()?.parse::<u32>().ok()?;
815 let h = parts.next()?.parse::<u32>().ok()?;
816 Some((w, h))
817 })
818 .ok_or_else(|| {
819 AppError::Stego(StegoError::MalformedCoverData {
820 reason: "--model requires --resolution in WIDTHxHEIGHT format \
821 (e.g. --resolution 1024x1024)"
822 .to_string(),
823 })
824 })?;
825 CorpusService::search_for_model(&index, &payload, model_id, res, *top)?
826 } else {
827 CorpusService::search(&index, &payload, tech, *top)?
828 };
829
830 for entry in &results {
831 println!("{}", entry.path);
832 }
833 }
834 }
835 Ok(())
836}
837
838fn cmd_panic(args: &cli::PanicArgs) -> Result<(), AppError> {
841 let config = crate::domain::types::PanicWipeConfig {
842 key_paths: args.key_paths.iter().map(PathBuf::from).collect(),
843 config_paths: Vec::new(),
844 temp_dirs: Vec::new(),
845 };
846 let wiper = crate::adapters::opsec::SecurePanicWiper::new();
847 crate::application::services::PanicWipeService::wipe(&config, &wiper)?;
848 Ok(())
849}
850
851fn cmd_completions(args: &cli::CompletionsArgs) -> Result<(), AppError> {
854 use clap::CommandFactory;
855 use clap_complete::generate;
856
857 let mut cmd = Cli::command();
858 match &args.output {
859 Some(path) => {
860 let mut file = std::fs::File::create(path).map_err(|e| {
861 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
862 reason: format!("write {}: {e}", path.display()),
863 })
864 })?;
865 generate(args.shell, &mut cmd, "shadowforge", &mut file);
866 }
867 None => {
868 generate(args.shell, &mut cmd, "shadowforge", &mut io::stdout());
869 }
870 }
871 Ok(())
872}
873
874const AES_GCM_NONCE_LEN: usize = 12;
878
879fn cmd_cipher(args: &cli::CipherArgs) -> Result<(), AppError> {
880 use rand_core::Rng as _;
881
882 let cipher = crate::adapters::crypto::Aes256GcmCipher;
883 match &args.subcmd {
884 cli::CipherSubcommand::Encrypt { input, key, output } => {
885 let plaintext = fs_read(input)?;
886 let key_bytes = fs_read(key)?;
887 let mut nonce = [0u8; AES_GCM_NONCE_LEN];
888 rand::rng().fill_bytes(&mut nonce);
889 let ciphertext = CipherService::encrypt(&cipher, &key_bytes, &nonce, &plaintext)?;
890 let mut out = Vec::with_capacity(AES_GCM_NONCE_LEN.strict_add(ciphertext.len()));
891 out.extend_from_slice(&nonce);
892 out.extend_from_slice(&ciphertext);
893 fs_write(output, &out)?;
894 eprintln!(
895 "Encrypted {} bytes -> {}",
896 plaintext.len(),
897 output.display()
898 );
899 }
900 cli::CipherSubcommand::Decrypt { input, key, output } => {
901 let data = fs_read(input)?;
902 let key_bytes = fs_read(key)?;
903 if data.len() < AES_GCM_NONCE_LEN {
904 return Err(AppError::Crypto(
905 crate::domain::errors::CryptoError::InvalidNonceLength {
906 expected: AES_GCM_NONCE_LEN,
907 got: data.len(),
908 },
909 ));
910 }
911 let (nonce, ciphertext) = data.split_at(AES_GCM_NONCE_LEN);
912 let plaintext = CipherService::decrypt(&cipher, &key_bytes, nonce, ciphertext)?;
913 fs_write(output, &plaintext)?;
914 eprintln!(
915 "Decrypted {} bytes -> {}",
916 plaintext.len(),
917 output.display()
918 );
919 }
920 }
921 Ok(())
922}
923
924fn fs_read(path: &Path) -> Result<Vec<u8>, AppError> {
929 std::fs::read(path).map_err(|e| {
930 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
931 reason: format!("read {}: {e}", path.display()),
932 })
933 })
934}
935
936fn fs_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
937 if let Some(parent) = path.parent() {
938 fs_create_dir_all(parent)?;
939 }
940 std::fs::write(path, data).map_err(|e| {
941 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
942 reason: format!("write {}: {e}", path.display()),
943 })
944 })
945}
946
947fn fs_create_dir_all(path: &Path) -> Result<(), AppError> {
948 std::fs::create_dir_all(path).map_err(|e| {
949 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
950 reason: format!("mkdir {}: {e}", path.display()),
951 })
952 })
953}
954
955fn map_media_error(error: crate::domain::errors::MediaError) -> AppError {
956 match error {
957 crate::domain::errors::MediaError::UnsupportedFormat { extension } => {
958 AppError::Stego(crate::domain::errors::StegoError::UnsupportedCoverType {
959 reason: format!("unsupported cover format: {extension}"),
960 })
961 }
962 crate::domain::errors::MediaError::DecodeFailed { reason }
963 | crate::domain::errors::MediaError::EncodeFailed { reason }
964 | crate::domain::errors::MediaError::IoError { reason } => {
965 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData { reason })
966 }
967 }
968}
969
970#[cfg(feature = "pdf")]
971fn map_pdf_runner_error(error: crate::domain::errors::PdfError) -> AppError {
972 match error {
973 crate::domain::errors::PdfError::Encrypted => {
974 AppError::Stego(crate::domain::errors::StegoError::UnsupportedCoverType {
975 reason: "encrypted PDF documents are not supported".to_string(),
976 })
977 }
978 crate::domain::errors::PdfError::ParseFailed { reason }
979 | crate::domain::errors::PdfError::RenderFailed { reason, .. }
980 | crate::domain::errors::PdfError::RebuildFailed { reason }
981 | crate::domain::errors::PdfError::EmbedFailed { reason }
982 | crate::domain::errors::PdfError::ExtractFailed { reason }
983 | crate::domain::errors::PdfError::IoError { reason } => {
984 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData { reason })
985 }
986 }
987}
988
989fn load_cover_from_path(path: &Path, technique: StegoTechnique) -> Result<CoverMedia, AppError> {
990 match technique {
991 StegoTechnique::LsbAudio | StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
992 let loader = crate::adapters::media::AudioMediaLoader;
993 loader.load(path).map_err(map_media_error)
994 }
995 StegoTechnique::PdfContentStream | StegoTechnique::PdfMetadata => load_pdf_cover(path),
996 StegoTechnique::ZeroWidthText => {
997 let data = fs_read(path)?;
998 Ok(load_cover(technique, &data))
999 }
1000 StegoTechnique::LsbImage
1001 | StegoTechnique::DctJpeg
1002 | StegoTechnique::Palette
1003 | StegoTechnique::CorpusSelection
1004 | StegoTechnique::DualPayload => {
1005 let loader = crate::adapters::media::ImageMediaLoader;
1006 loader.load(path).map_err(map_media_error)
1007 }
1008 }
1009}
1010
1011#[cfg(feature = "pdf")]
1012fn load_pdf_cover(path: &Path) -> Result<CoverMedia, AppError> {
1013 use crate::domain::ports::PdfProcessor;
1014
1015 let processor = crate::adapters::pdf::PdfProcessorImpl::default();
1016 processor.load_pdf(path).map_err(map_pdf_runner_error)
1017}
1018
1019#[cfg(not(feature = "pdf"))]
1020fn load_pdf_cover(_path: &Path) -> Result<CoverMedia, AppError> {
1021 Err(AppError::Stego(
1022 crate::domain::errors::StegoError::UnsupportedCoverType {
1023 reason: "PDF support is not enabled in this build".to_string(),
1024 },
1025 ))
1026}
1027
1028fn save_cover_to_path(media: &CoverMedia, path: &Path) -> Result<(), AppError> {
1029 if let Some(parent) = path.parent() {
1030 fs_create_dir_all(parent)?;
1031 }
1032
1033 match media.kind {
1034 CoverMediaKind::PngImage
1035 | CoverMediaKind::BmpImage
1036 | CoverMediaKind::JpegImage
1037 | CoverMediaKind::GifImage => {
1038 let loader = crate::adapters::media::ImageMediaLoader;
1039 loader.save(media, path).map_err(map_media_error)
1040 }
1041 CoverMediaKind::WavAudio => {
1042 let loader = crate::adapters::media::AudioMediaLoader;
1043 loader.save(media, path).map_err(map_media_error)
1044 }
1045 CoverMediaKind::PdfDocument => save_pdf_cover(media, path),
1046 CoverMediaKind::PlainText => fs_write(path, media.data.as_ref()),
1047 }
1048}
1049
1050#[cfg(feature = "pdf")]
1051fn save_pdf_cover(media: &CoverMedia, path: &Path) -> Result<(), AppError> {
1052 use crate::domain::ports::PdfProcessor;
1053
1054 let processor = crate::adapters::pdf::PdfProcessorImpl::default();
1055 processor
1056 .save_pdf(media, path)
1057 .map_err(map_pdf_runner_error)
1058}
1059
1060#[cfg(not(feature = "pdf"))]
1061fn save_pdf_cover(_media: &CoverMedia, _path: &Path) -> Result<(), AppError> {
1062 Err(AppError::Stego(
1063 crate::domain::errors::StegoError::UnsupportedCoverType {
1064 reason: "PDF support is not enabled in this build".to_string(),
1065 },
1066 ))
1067}
1068
1069fn serialise_cover_to_bytes(media: &CoverMedia) -> Result<Vec<u8>, AppError> {
1070 match media.kind {
1071 CoverMediaKind::PdfDocument | CoverMediaKind::PlainText => Ok(media.data.to_vec()),
1072 _ => {
1073 let temp_path = std::env::temp_dir().join(format!(
1074 "shadowforge-{}.{}",
1075 uuid::Uuid::new_v4(),
1076 cover_file_extension(media.kind)
1077 ));
1078 let result = (|| {
1079 save_cover_to_path(media, &temp_path)?;
1080 fs_read(&temp_path)
1081 })();
1082 let _ = std::fs::remove_file(&temp_path);
1083 result
1084 }
1085 }
1086}
1087
1088fn load_cover_from_named_bytes(
1089 name: &str,
1090 technique: StegoTechnique,
1091 data: &[u8],
1092) -> Result<CoverMedia, AppError> {
1093 if technique == StegoTechnique::ZeroWidthText {
1094 return Ok(load_cover(technique, data));
1095 }
1096
1097 let extension = Path::new(name)
1098 .extension()
1099 .and_then(|value| value.to_str())
1100 .unwrap_or_else(|| technique_file_extension(technique));
1101 let temp_path = std::env::temp_dir().join(format!(
1102 "shadowforge-{}.{}",
1103 uuid::Uuid::new_v4(),
1104 extension
1105 ));
1106 let result = (|| {
1107 fs_write(&temp_path, data)?;
1108 load_cover_from_path(&temp_path, technique)
1109 })();
1110 let _ = std::fs::remove_file(&temp_path);
1111 result
1112}
1113
1114const fn cover_file_extension(kind: CoverMediaKind) -> &'static str {
1115 match kind {
1116 CoverMediaKind::PngImage => "png",
1117 CoverMediaKind::BmpImage => "bmp",
1118 CoverMediaKind::JpegImage => "jpg",
1119 CoverMediaKind::GifImage => "gif",
1120 CoverMediaKind::WavAudio => "wav",
1121 CoverMediaKind::PdfDocument => "pdf",
1122 CoverMediaKind::PlainText => "txt",
1123 }
1124}
1125
1126const fn technique_file_extension(technique: StegoTechnique) -> &'static str {
1127 match technique {
1128 StegoTechnique::LsbAudio | StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
1129 "wav"
1130 }
1131 StegoTechnique::PdfContentStream | StegoTechnique::PdfMetadata => "pdf",
1132 StegoTechnique::ZeroWidthText => "txt",
1133 StegoTechnique::DctJpeg => "jpg",
1134 StegoTechnique::LsbImage
1135 | StegoTechnique::Palette
1136 | StegoTechnique::CorpusSelection
1137 | StegoTechnique::DualPayload => "png",
1138 }
1139}
1140
1141fn load_cover(technique: crate::domain::types::StegoTechnique, data: &[u8]) -> CoverMedia {
1142 let kind = match technique {
1143 crate::domain::types::StegoTechnique::LsbAudio
1144 | crate::domain::types::StegoTechnique::PhaseEncoding
1145 | crate::domain::types::StegoTechnique::EchoHiding => CoverMediaKind::WavAudio,
1146 crate::domain::types::StegoTechnique::ZeroWidthText => CoverMediaKind::PlainText,
1147 crate::domain::types::StegoTechnique::PdfContentStream
1148 | crate::domain::types::StegoTechnique::PdfMetadata => CoverMediaKind::PdfDocument,
1149 crate::domain::types::StegoTechnique::DctJpeg => CoverMediaKind::JpegImage,
1150 crate::domain::types::StegoTechnique::LsbImage
1151 | crate::domain::types::StegoTechnique::Palette
1152 | crate::domain::types::StegoTechnique::CorpusSelection
1153 | crate::domain::types::StegoTechnique::DualPayload => CoverMediaKind::PngImage,
1154 };
1155 CoverMedia {
1156 kind,
1157 data: bytes::Bytes::from(data.to_vec()),
1158 metadata: std::collections::HashMap::new(),
1159 }
1160}
1161
1162fn build_embedder(technique: crate::domain::types::StegoTechnique) -> Box<dyn EmbedTechnique> {
1164 use crate::domain::types::StegoTechnique;
1165 match technique {
1166 StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
1167 StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
1168 StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
1169 StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
1170 StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
1171 StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
1172 StegoTechnique::PdfContentStream => build_pdf_content_stream_embedder(),
1173 StegoTechnique::PdfMetadata => build_pdf_metadata_embedder(),
1174 StegoTechnique::CorpusSelection => Box::new(UnsupportedTechnique::new(
1175 StegoTechnique::CorpusSelection,
1176 "corpus selection must use the corpus workflow, not generic embed",
1177 )),
1178 StegoTechnique::DualPayload => Box::new(UnsupportedTechnique::new(
1179 StegoTechnique::DualPayload,
1180 "dual-payload embedding must use the deniable embed workflow",
1181 )),
1182 StegoTechnique::LsbImage => Box::new(crate::adapters::stego::LsbImage::new()),
1183 }
1184}
1185
1186fn build_extractor(technique: crate::domain::types::StegoTechnique) -> Box<dyn ExtractTechnique> {
1188 use crate::domain::types::StegoTechnique;
1189 match technique {
1190 StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
1191 StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
1192 StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
1193 StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
1194 StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
1195 StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
1196 StegoTechnique::PdfContentStream => build_pdf_content_stream_extractor(),
1197 StegoTechnique::PdfMetadata => build_pdf_metadata_extractor(),
1198 StegoTechnique::CorpusSelection => Box::new(UnsupportedTechnique::new(
1199 StegoTechnique::CorpusSelection,
1200 "corpus selection must use the corpus workflow, not generic extract",
1201 )),
1202 StegoTechnique::DualPayload => Box::new(UnsupportedTechnique::new(
1203 StegoTechnique::DualPayload,
1204 "dual-payload extraction must use the deniable extraction workflow",
1205 )),
1206 StegoTechnique::LsbImage => Box::new(crate::adapters::stego::LsbImage::new()),
1207 }
1208}
1209
1210#[cfg(feature = "pdf")]
1211fn build_pdf_content_stream_embedder() -> Box<dyn EmbedTechnique> {
1212 Box::new(crate::adapters::pdf::PdfContentStreamStego::new())
1213}
1214
1215#[cfg(not(feature = "pdf"))]
1216fn build_pdf_content_stream_embedder() -> Box<dyn EmbedTechnique> {
1217 Box::new(UnsupportedTechnique::new(
1218 crate::domain::types::StegoTechnique::PdfContentStream,
1219 "PDF support is not enabled in this build",
1220 ))
1221}
1222
1223#[cfg(feature = "pdf")]
1224fn build_pdf_metadata_embedder() -> Box<dyn EmbedTechnique> {
1225 Box::new(crate::adapters::pdf::PdfMetadataStego::new())
1226}
1227
1228#[cfg(not(feature = "pdf"))]
1229fn build_pdf_metadata_embedder() -> Box<dyn EmbedTechnique> {
1230 Box::new(UnsupportedTechnique::new(
1231 crate::domain::types::StegoTechnique::PdfMetadata,
1232 "PDF support is not enabled in this build",
1233 ))
1234}
1235
1236#[cfg(feature = "pdf")]
1237fn build_pdf_content_stream_extractor() -> Box<dyn ExtractTechnique> {
1238 Box::new(crate::adapters::pdf::PdfContentStreamStego::new())
1239}
1240
1241#[cfg(not(feature = "pdf"))]
1242fn build_pdf_content_stream_extractor() -> Box<dyn ExtractTechnique> {
1243 Box::new(UnsupportedTechnique::new(
1244 crate::domain::types::StegoTechnique::PdfContentStream,
1245 "PDF support is not enabled in this build",
1246 ))
1247}
1248
1249#[cfg(feature = "pdf")]
1250fn build_pdf_metadata_extractor() -> Box<dyn ExtractTechnique> {
1251 Box::new(crate::adapters::pdf::PdfMetadataStego::new())
1252}
1253
1254#[cfg(not(feature = "pdf"))]
1255fn build_pdf_metadata_extractor() -> Box<dyn ExtractTechnique> {
1256 Box::new(UnsupportedTechnique::new(
1257 crate::domain::types::StegoTechnique::PdfMetadata,
1258 "PDF support is not enabled in this build",
1259 ))
1260}
1261
1262#[derive(Debug)]
1263struct UnsupportedTechnique {
1264 technique: crate::domain::types::StegoTechnique,
1265 reason: &'static str,
1266}
1267
1268impl UnsupportedTechnique {
1269 const fn new(technique: crate::domain::types::StegoTechnique, reason: &'static str) -> Self {
1270 Self { technique, reason }
1271 }
1272}
1273
1274impl EmbedTechnique for UnsupportedTechnique {
1275 fn technique(&self) -> crate::domain::types::StegoTechnique {
1276 self.technique
1277 }
1278
1279 fn capacity(&self, _cover: &CoverMedia) -> Result<crate::domain::types::Capacity, StegoError> {
1280 Err(StegoError::UnsupportedCoverType {
1281 reason: self.reason.to_string(),
1282 })
1283 }
1284
1285 fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
1286 Err(StegoError::UnsupportedCoverType {
1287 reason: self.reason.to_string(),
1288 })
1289 }
1290}
1291
1292impl ExtractTechnique for UnsupportedTechnique {
1293 fn technique(&self) -> crate::domain::types::StegoTechnique {
1294 self.technique
1295 }
1296
1297 fn extract(&self, _stego: &CoverMedia) -> Result<Payload, StegoError> {
1298 Err(StegoError::UnsupportedCoverType {
1299 reason: self.reason.to_string(),
1300 })
1301 }
1302}
1303
1304const fn resolve_technique(t: cli::Technique) -> crate::domain::types::StegoTechnique {
1305 match t {
1306 cli::Technique::Lsb => crate::domain::types::StegoTechnique::LsbImage,
1307 cli::Technique::Dct => crate::domain::types::StegoTechnique::DctJpeg,
1308 cli::Technique::Palette => crate::domain::types::StegoTechnique::Palette,
1309 cli::Technique::LsbAudio => crate::domain::types::StegoTechnique::LsbAudio,
1310 cli::Technique::Phase => crate::domain::types::StegoTechnique::PhaseEncoding,
1311 cli::Technique::Echo => crate::domain::types::StegoTechnique::EchoHiding,
1312 cli::Technique::ZeroWidth => crate::domain::types::StegoTechnique::ZeroWidthText,
1313 cli::Technique::PdfStream => crate::domain::types::StegoTechnique::PdfContentStream,
1314 cli::Technique::PdfMeta => crate::domain::types::StegoTechnique::PdfMetadata,
1315 cli::Technique::Corpus => crate::domain::types::StegoTechnique::CorpusSelection,
1316 }
1317}
1318
1319fn resolve_profile(
1320 profile: cli::Profile,
1321 platform: Option<cli::Platform>,
1322) -> crate::domain::types::EmbeddingProfile {
1323 match profile {
1324 cli::Profile::Standard => crate::domain::types::EmbeddingProfile::Standard,
1325 cli::Profile::Adaptive => crate::domain::types::EmbeddingProfile::default_adaptive(),
1326 cli::Profile::Survivable => {
1327 let p = platform.map_or(crate::domain::types::PlatformProfile::Instagram, |pl| {
1328 resolve_platform(pl)
1329 });
1330 crate::domain::types::EmbeddingProfile::CompressionSurvivable { platform: p }
1331 }
1332 }
1333}
1334
1335const fn resolve_platform(p: cli::Platform) -> crate::domain::types::PlatformProfile {
1336 match p {
1337 cli::Platform::Instagram => crate::domain::types::PlatformProfile::Instagram,
1338 cli::Platform::Twitter => crate::domain::types::PlatformProfile::Twitter,
1339 cli::Platform::Whatsapp => crate::domain::types::PlatformProfile::WhatsApp,
1340 cli::Platform::Telegram => crate::domain::types::PlatformProfile::Telegram,
1341 cli::Platform::Imgur => crate::domain::types::PlatformProfile::Imgur,
1342 }
1343}
1344
1345const fn resolve_archive_format(f: cli::ArchiveFormat) -> crate::domain::types::ArchiveFormat {
1346 match f {
1347 cli::ArchiveFormat::Zip => crate::domain::types::ArchiveFormat::Zip,
1348 cli::ArchiveFormat::Tar => crate::domain::types::ArchiveFormat::Tar,
1349 cli::ArchiveFormat::TarGz => crate::domain::types::ArchiveFormat::TarGz,
1350 }
1351}
1352
1353fn collect_cover_paths(pattern: &str) -> Result<Vec<PathBuf>, AppError> {
1356 let path = PathBuf::from(pattern);
1357 let paths: Vec<PathBuf> = if path.is_dir() {
1358 std::fs::read_dir(&path)
1359 .map_err(
1360 |_| crate::domain::errors::DistributionError::InsufficientCovers {
1361 needed: 1,
1362 got: 0,
1363 },
1364 )?
1365 .filter_map(Result::ok)
1366 .map(|e| e.path())
1367 .filter(|p| p.is_file())
1368 .collect()
1369 } else if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
1370 glob::glob(pattern)
1371 .map_err(
1372 |_| crate::domain::errors::DistributionError::InsufficientCovers {
1373 needed: 1,
1374 got: 0,
1375 },
1376 )?
1377 .filter_map(Result::ok)
1378 .filter(|p| p.is_file())
1379 .collect()
1380 } else {
1381 vec![path]
1383 };
1384
1385 if paths.is_empty() {
1386 return Err(
1387 crate::domain::errors::DistributionError::InsufficientCovers { needed: 1, got: 0 }
1388 .into(),
1389 );
1390 }
1391 Ok(paths)
1392}
1393
1394fn load_watermark_tags(
1395 dir: &Path,
1396) -> Result<Vec<crate::domain::types::WatermarkTripwireTag>, AppError> {
1397 let mut tags = Vec::new();
1398 if dir.is_dir() {
1399 let entries = std::fs::read_dir(dir).map_err(|e| {
1400 crate::domain::errors::OpsecError::WatermarkError {
1401 reason: format!("read dir: {e}"),
1402 }
1403 })?;
1404 for entry in entries {
1405 let entry = entry.map_err(|e: std::io::Error| {
1406 crate::domain::errors::OpsecError::WatermarkError {
1407 reason: format!("dir entry: {e}"),
1408 }
1409 })?;
1410 let path = entry.path();
1411 if path.is_file() {
1412 if let Ok(content) = std::fs::read_to_string(&path) {
1414 let id_str = content.trim();
1415 let rid = uuid::Uuid::parse_str(id_str).map_err(|e| {
1416 crate::domain::errors::OpsecError::WatermarkError {
1417 reason: format!("invalid UUID in {}: {e}", path.display()),
1418 }
1419 })?;
1420 tags.push(crate::domain::types::WatermarkTripwireTag {
1421 recipient_id: rid,
1422 embedding_seed: id_str.as_bytes().to_vec(),
1423 });
1424 }
1425 }
1426 }
1427 }
1428 Ok(tags)
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433 use super::{
1434 build_embedder, build_extractor, cmd_extract, cmd_keygen, load_cover, load_cover_from_path,
1435 save_cover_to_path,
1436 };
1437 use crate::application::services::{AppError, DeniableEmbedService};
1438 use crate::domain::errors::CryptoError;
1439 use crate::domain::types::{CoverMediaKind, StegoTechnique};
1440 use crate::interface::cli;
1441 use image::{DynamicImage, Rgba, RgbaImage};
1442 use std::fs;
1443 use tempfile::tempdir;
1444
1445 type TestResult = Result<(), Box<dyn std::error::Error>>;
1446
1447 #[test]
1448 fn dct_cover_is_classified_as_jpeg() {
1449 let cover = load_cover(StegoTechnique::DctJpeg, b"jpeg");
1450 assert_eq!(cover.kind, CoverMediaKind::JpegImage);
1451 }
1452
1453 #[test]
1454 fn pdf_cover_is_classified_as_pdf() {
1455 let cover = load_cover(StegoTechnique::PdfContentStream, b"%PDF-1.7");
1456 assert_eq!(cover.kind, CoverMediaKind::PdfDocument);
1457 }
1458
1459 #[test]
1460 fn pdf_content_stream_embedder_uses_pdf_technique() {
1461 let embedder = build_embedder(StegoTechnique::PdfContentStream);
1462 assert_eq!(embedder.technique(), StegoTechnique::PdfContentStream);
1463 }
1464
1465 #[test]
1466 fn pdf_metadata_extractor_uses_pdf_technique() {
1467 let extractor = build_extractor(StegoTechnique::PdfMetadata);
1468 assert_eq!(extractor.technique(), StegoTechnique::PdfMetadata);
1469 }
1470
1471 #[test]
1472 fn corpus_embedder_is_not_rewritten_as_lsb_image() {
1473 let embedder = build_embedder(StegoTechnique::CorpusSelection);
1474 assert_eq!(embedder.technique(), StegoTechnique::CorpusSelection);
1475 }
1476
1477 #[test]
1478 fn file_backed_image_covers_use_media_loader() -> TestResult {
1479 let dir = tempdir()?;
1480 let input_path = dir.path().join("cover.png");
1481 let output_path = dir.path().join("roundtrip.png");
1482
1483 let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(3, 2, Rgba([1, 2, 3, 255])));
1484 image.save(&input_path)?;
1485
1486 let cover = load_cover_from_path(&input_path, StegoTechnique::LsbImage)?;
1487 assert_eq!(cover.kind, CoverMediaKind::PngImage);
1488 assert_eq!(cover.metadata.get("width"), Some(&"3".to_string()));
1489 assert_eq!(cover.metadata.get("height"), Some(&"2".to_string()));
1490
1491 save_cover_to_path(&cover, &output_path)?;
1492 let written = fs::read(output_path)?;
1493 assert!(!written.is_empty());
1494 Ok(())
1495 }
1496
1497 #[test]
1498 fn extract_uses_deniable_key_path_when_provided() -> TestResult {
1499 let dir = tempdir()?;
1500 let cover_path = dir.path().join("cover.png");
1501 let input_path = dir.path().join("input.png");
1502 let output_path = dir.path().join("output.bin");
1503 let key_path = dir.path().join("primary.key");
1504
1505 let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(40, 40, Rgba([0, 0, 0, 255])));
1506 image.save(&cover_path)?;
1507
1508 let primary_key = vec![7u8; 32];
1509 let decoy_key = vec![9u8; 32];
1510 let pair = crate::domain::types::DeniablePayloadPair {
1511 real_payload: b"real secret".to_vec(),
1512 decoy_payload: b"decoy".to_vec(),
1513 };
1514 let keys = crate::domain::types::DeniableKeySet {
1515 primary_key: primary_key.clone(),
1516 decoy_key,
1517 };
1518 let cover = load_cover_from_path(&cover_path, StegoTechnique::LsbImage)?;
1519 let deniable = crate::adapters::stego::DualPayloadEmbedder;
1520 let embedder = build_embedder(StegoTechnique::LsbImage);
1521 let stego =
1522 DeniableEmbedService::embed_dual(cover, &pair, &keys, embedder.as_ref(), &deniable)?;
1523
1524 save_cover_to_path(&stego, &input_path)?;
1525 fs::write(&key_path, &primary_key)?;
1526
1527 let args = cli::ExtractArgs {
1528 input: input_path,
1529 output: output_path.clone(),
1530 technique: cli::Technique::Lsb,
1531 key: Some(key_path),
1532 amnesia: false,
1533 };
1534
1535 cmd_extract(&args)?;
1536
1537 let extracted = fs::read(output_path)?;
1538 assert_eq!(extracted, b"real secret");
1539 Ok(())
1540 }
1541
1542 #[test]
1543 fn keygen_sign_produces_signature_artifact() -> TestResult {
1544 let dir = tempdir()?;
1545 let keys_dir = dir.path().join("keys");
1546 let msg_path = dir.path().join("message.bin");
1547 let sig_path = dir.path().join("message.sig");
1548
1549 let generate = cli::KeygenArgs {
1550 subcmd: None,
1551 algorithm: Some(cli::Algorithm::Dilithium3),
1552 output: Some(keys_dir.clone()),
1553 };
1554 cmd_keygen(&generate)?;
1555
1556 fs::write(&msg_path, b"hello signer")?;
1557 let sign = cli::KeygenArgs {
1558 subcmd: Some(cli::KeygenSubcommand::Sign {
1559 input: msg_path,
1560 secret_key: keys_dir.join("secret.key"),
1561 output: sig_path.clone(),
1562 }),
1563 algorithm: None,
1564 output: None,
1565 };
1566 cmd_keygen(&sign)?;
1567
1568 let sig = fs::read(sig_path)?;
1569 assert!(!sig.is_empty());
1570 Ok(())
1571 }
1572
1573 #[test]
1574 fn keygen_verify_reports_success_and_failure_status() -> TestResult {
1575 let dir = tempdir()?;
1576 let keys_dir = dir.path().join("keys");
1577 let msg_path = dir.path().join("message.bin");
1578 let tampered_path = dir.path().join("message_tampered.bin");
1579 let sig_path = dir.path().join("message.sig");
1580
1581 let generate = cli::KeygenArgs {
1582 subcmd: None,
1583 algorithm: Some(cli::Algorithm::Dilithium3),
1584 output: Some(keys_dir.clone()),
1585 };
1586 cmd_keygen(&generate)?;
1587
1588 fs::write(&msg_path, b"signed message")?;
1589 fs::write(&tampered_path, b"signed message with tamper")?;
1590
1591 let sign = cli::KeygenArgs {
1592 subcmd: Some(cli::KeygenSubcommand::Sign {
1593 input: msg_path.clone(),
1594 secret_key: keys_dir.join("secret.key"),
1595 output: sig_path.clone(),
1596 }),
1597 algorithm: None,
1598 output: None,
1599 };
1600 cmd_keygen(&sign)?;
1601
1602 let verify_ok = cli::KeygenArgs {
1603 subcmd: Some(cli::KeygenSubcommand::Verify {
1604 input: msg_path,
1605 public_key: keys_dir.join("public.key"),
1606 signature: sig_path.clone(),
1607 }),
1608 algorithm: None,
1609 output: None,
1610 };
1611 assert!(cmd_keygen(&verify_ok).is_ok());
1612
1613 let verify_fail = cli::KeygenArgs {
1614 subcmd: Some(cli::KeygenSubcommand::Verify {
1615 input: tampered_path,
1616 public_key: keys_dir.join("public.key"),
1617 signature: sig_path,
1618 }),
1619 algorithm: None,
1620 output: None,
1621 };
1622 let err = cmd_keygen(&verify_fail).err();
1623 assert!(matches!(
1624 err,
1625 Some(AppError::Crypto(CryptoError::VerificationFailed { .. }))
1626 ));
1627 Ok(())
1628 }
1629
1630 use super::format_analysis_report;
1633 use crate::domain::types::{
1634 AiWatermarkAssessment, AnalysisReport, Capacity, DetectabilityRisk, SpectralScore,
1635 StegoTechnique as ST,
1636 };
1637
1638 fn base_report() -> AnalysisReport {
1639 AnalysisReport {
1640 technique: ST::LsbImage,
1641 cover_capacity: Capacity {
1642 bytes: 1024,
1643 technique: ST::LsbImage,
1644 },
1645 chi_square_score: std::f64::consts::PI,
1646 detectability_risk: DetectabilityRisk::Low,
1647 recommended_max_payload_bytes: 512,
1648 ai_watermark: None,
1649 spectral_score: None,
1650 }
1651 }
1652
1653 #[test]
1654 fn format_report_contains_core_fields() {
1655 let report = base_report();
1656 let out = format_analysis_report(&report);
1657 assert!(out.contains("LsbImage"), "technique should appear");
1658 assert!(out.contains("1024 bytes"), "capacity should appear");
1659 assert!(out.contains("3.14 dB"), "chi-square should appear");
1660 assert!(out.contains("Low"), "risk should appear");
1661 assert!(out.contains("512 bytes"), "recommended max should appear");
1662 }
1663
1664 #[test]
1665 fn format_report_omits_spectral_section_when_none() {
1666 let report = base_report();
1667 let out = format_analysis_report(&report);
1668 assert!(
1669 !out.contains("Spectral Detectability"),
1670 "spectral section must be absent when score is None"
1671 );
1672 }
1673
1674 #[test]
1675 fn format_report_includes_spectral_section_when_present() {
1676 let mut report = base_report();
1677 report.spectral_score = Some(SpectralScore {
1678 phase_coherence_drop: 0.1234,
1679 carrier_snr_drop_db: -2.5,
1680 sample_pair_asymmetry: 0.0081,
1681 combined_risk: DetectabilityRisk::Medium,
1682 });
1683 let out = format_analysis_report(&report);
1684 assert!(
1685 out.contains("Spectral Detectability"),
1686 "section header must appear"
1687 );
1688 assert!(out.contains("0.1234"), "phase coherence drop should appear");
1689 assert!(out.contains("-2.50 dB"), "SNR drop should appear");
1690 assert!(
1691 out.contains("0.0081"),
1692 "sample-pair asymmetry should appear"
1693 );
1694 assert!(out.contains("Medium"), "spectral risk should appear");
1695 }
1696
1697 #[test]
1698 fn format_report_spectral_does_not_bleed_into_core_output() {
1699 let report = base_report();
1700 let out = format_analysis_report(&report);
1701 assert!(out.contains("Technique"));
1703 assert!(out.contains("Chi-square"));
1704 assert!(out.contains("Recommended"));
1705 }
1706
1707 #[test]
1708 fn format_report_includes_ai_watermark_section_when_present() {
1709 let mut report = base_report();
1710 report.ai_watermark = Some(AiWatermarkAssessment {
1711 detected: true,
1712 model_id: Some("gemini".to_string()),
1713 confidence: 0.875,
1714 matched_strong_bins: 7,
1715 total_strong_bins: 8,
1716 });
1717
1718 let out = format_analysis_report(&report);
1719 assert!(out.contains("AI Watermark Detection"));
1720 assert!(out.contains("yes"));
1721 assert!(out.contains("gemini"));
1722 assert!(out.contains("0.8750"));
1723 assert!(out.contains("7/8"));
1724 }
1725}