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