1use std::io::{self, Read, Write};
4use std::path::{Path, PathBuf};
5
6const MAX_STDIN_PAYLOAD: u64 = 256 * 1024 * 1024;
8
9use clap::Parser;
10
11use crate::application::services::{
12 AnalyseService, AppError, ArchiveService, EmbedService, ExtractService, KeyGenService,
13 ScrubService,
14};
15use crate::domain::errors::{CanaryError, OpsecError, StegoError};
16use crate::domain::ports::{EmbedTechnique, ExtractTechnique, GeographicDistributor, MediaLoader};
17use crate::domain::types::{CoverMedia, CoverMediaKind, Payload, 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 }
55}
56
57fn print_version() {
58 let version = env!("CARGO_PKG_VERSION");
59 let sha = option_env!("VERGEN_GIT_SHA").unwrap_or("unknown");
60 println!("shadowforge {version} ({sha})");
61}
62
63fn cmd_keygen(args: &cli::KeygenArgs) -> Result<(), AppError> {
66 let dir = &args.output;
67 fs_create_dir_all(dir)?;
68
69 match args.algorithm {
70 cli::Algorithm::Kyber1024 => {
71 let enc = crate::adapters::crypto::MlKemEncryptor;
72 let kp = KeyGenService::generate_keypair(&enc)?;
73 fs_write(&dir.join("public.key"), kp.public_key.as_ref())?;
74 fs_write(&dir.join("secret.key"), kp.secret_key.as_ref())?;
75 }
76 cli::Algorithm::Dilithium3 => {
77 let signer = crate::adapters::crypto::MlDsaSigner;
78 let kp = KeyGenService::generate_signing_keypair(&signer)?;
79 fs_write(&dir.join("public.key"), kp.public_key.as_ref())?;
80 fs_write(&dir.join("secret.key"), kp.secret_key.as_ref())?;
81 }
82 }
83 eprintln!("Keys written to {}", dir.display());
84 Ok(())
85}
86
87fn cmd_embed(args: &cli::EmbedArgs) -> Result<(), AppError> {
90 let technique = resolve_technique(args.technique);
91 let embedder = build_embedder(technique);
92
93 let payload_bytes = fs_read(&args.input)?;
94 let mut payload = Payload::from_bytes(payload_bytes);
95
96 if args.scrub_style {
97 let scrubber = crate::adapters::scrubber::StyloScrubberImpl::new();
98 let profile = crate::domain::types::StyloProfile {
99 normalize_punctuation: true,
100 target_avg_sentence_len: 15.0,
101 target_vocab_size: 1000,
102 };
103 let text = String::from_utf8_lossy(payload.as_bytes());
104 let result = ScrubService::scrub(&text, &profile, &scrubber)?;
105 payload = Payload::from_bytes(result.into_bytes());
106 }
107
108 if args.amnesia {
109 let pipeline = crate::adapters::opsec::AmnesiaPipelineImpl::new();
110 let mut payload_cursor = io::Cursor::new(payload.as_bytes().to_vec());
111 let mut cover_input = io::stdin().lock();
112 let mut output = io::stdout().lock();
113 crate::application::services::AmnesiaPipelineService::embed_in_memory(
114 &mut payload_cursor,
115 &mut cover_input,
116 &mut output,
117 embedder.as_ref(),
118 &pipeline,
119 )?;
120 } else if args.deniable {
121 let cover_path = args.cover.as_ref().ok_or_else(|| {
122 crate::domain::errors::DeniableError::EmbedFailed {
123 reason: "--cover is required for deniable embedding".into(),
124 }
125 })?;
126 let cover = load_cover_from_path(cover_path, technique)?;
127
128 let decoy_path = args.decoy_payload.as_ref().ok_or_else(|| {
129 crate::domain::errors::DeniableError::EmbedFailed {
130 reason: "--decoy-payload is required for deniable embedding".into(),
131 }
132 })?;
133 let decoy_bytes = fs_read(decoy_path)?;
134 let primary_key = match &args.key {
135 Some(p) => fs_read(p)?,
136 None => vec![0u8; 32],
137 };
138 let decoy_key = match &args.decoy_key {
139 Some(p) => fs_read(p)?,
140 None => vec![1u8; 32],
141 };
142 let pair = crate::domain::types::DeniablePayloadPair {
143 real_payload: payload.as_bytes().to_vec(),
144 decoy_payload: decoy_bytes,
145 };
146 let keys = crate::domain::types::DeniableKeySet {
147 primary_key,
148 decoy_key,
149 };
150 let deniable = crate::adapters::stego::DualPayloadEmbedder;
151 let stego = crate::application::services::DeniableEmbedService::embed_dual(
152 cover,
153 &pair,
154 &keys,
155 embedder.as_ref(),
156 &deniable,
157 )?;
158 save_cover_to_path(&stego, &args.output)?;
159 eprintln!("Deniable embedding complete");
160 } else {
161 if matches!(args.profile, cli::Profile::Standard) {
164 } else {
166 eprintln!(
167 "Note: --profile {:?} only applies to distributed embedding; \
168 single-cover uses technique directly",
169 args.profile
170 );
171 }
172
173 let cover_path = args.cover.as_ref().ok_or_else(|| {
174 crate::domain::errors::StegoError::MalformedCoverData {
175 reason: "--cover is required when not using --amnesia".into(),
176 }
177 })?;
178 let cover = load_cover_from_path(cover_path, technique)?;
179 let stego = EmbedService::embed(cover, &payload, embedder.as_ref())?;
180 save_cover_to_path(&stego, &args.output)?;
181 eprintln!("Embedded {} bytes", payload.len());
182 }
183 Ok(())
184}
185
186fn cmd_extract(args: &cli::ExtractArgs) -> Result<(), AppError> {
189 let technique = resolve_technique(args.technique);
190 let extractor = build_extractor(technique);
191 let deniable_key = match &args.key {
192 Some(path) => Some(fs_read(path)?),
193 None => None,
194 };
195
196 if args.amnesia {
197 let mut buf = Vec::new();
198 io::stdin()
199 .lock()
200 .take(MAX_STDIN_PAYLOAD)
201 .read_to_end(&mut buf)
202 .map_err(|e| crate::domain::errors::StegoError::MalformedCoverData {
203 reason: format!("stdin read: {e}"),
204 })?;
205 let cover = load_cover(technique, &buf);
206 let payload = if let Some(key) = deniable_key.as_deref() {
207 let deniable = crate::adapters::stego::DualPayloadEmbedder;
208 crate::application::services::DeniableEmbedService::extract_with_key(
209 &cover,
210 key,
211 extractor.as_ref(),
212 &deniable,
213 )?
214 } else {
215 ExtractService::extract(&cover, extractor.as_ref())?
216 };
217 io::stdout().write_all(payload.as_bytes()).map_err(|e| {
218 crate::domain::errors::StegoError::MalformedCoverData {
219 reason: format!("stdout write: {e}"),
220 }
221 })?;
222 } else {
223 let stego = load_cover_from_path(&args.input, technique)?;
224 let payload = if let Some(key) = deniable_key.as_deref() {
225 let deniable = crate::adapters::stego::DualPayloadEmbedder;
226 crate::application::services::DeniableEmbedService::extract_with_key(
227 &stego,
228 key,
229 extractor.as_ref(),
230 &deniable,
231 )?
232 } else {
233 ExtractService::extract(&stego, extractor.as_ref())?
234 };
235 fs_write(&args.output, payload.as_bytes())?;
236 eprintln!("Extracted {} bytes", payload.len());
237 }
238 Ok(())
239}
240
241fn cmd_embed_distributed(args: &cli::EmbedDistributedArgs) -> Result<(), AppError> {
244 let technique = resolve_technique(args.technique);
245 let embedder = build_embedder(technique);
246
247 let payload_bytes = fs_read(&args.input)?;
248 let payload = Payload::from_bytes(payload_bytes);
249
250 let cover_paths = collect_cover_paths(&args.covers)?;
251 let covers: Result<Vec<CoverMedia>, AppError> = cover_paths
252 .iter()
253 .map(|path| load_cover_from_path(path, technique))
254 .collect();
255 let covers = covers?;
256
257 let profile = resolve_profile(args.profile, args.platform);
258 let (mut stego_covers, generated_hmac_key) =
259 distribute_covers(args, &payload, covers, &profile, embedder.as_ref())?;
260
261 let canary_metadata = if args.canary {
263 let canary_impl = crate::adapters::canary::CanaryServiceImpl::new(64, 5);
264 let (covers_with_canary, shard) =
265 crate::application::services::CanaryShardService::embed_canary(
266 stego_covers,
267 embedder.as_ref(),
268 &canary_impl,
269 )?;
270 stego_covers = covers_with_canary;
271 Some(shard)
272 } else {
273 None
274 };
275
276 let files: Vec<(String, Vec<u8>)> = stego_covers
277 .iter()
278 .enumerate()
279 .map(|(i, cover)| {
280 Ok((
281 format!("shard_{i:04}.{}", cover_file_extension(cover.kind)),
282 serialise_cover_to_bytes(cover)?,
283 ))
284 })
285 .collect::<Result<_, AppError>>()?;
286 let file_refs: Vec<(&str, &[u8])> = files
287 .iter()
288 .map(|(n, d)| (n.as_str(), d.as_slice()))
289 .collect();
290 let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
291 let archive = ArchiveService::pack(
292 &file_refs,
293 crate::domain::types::ArchiveFormat::Zip,
294 &handler,
295 )?;
296 fs_write(&args.output_archive, &archive)?;
297
298 if let Some(hmac_key) = generated_hmac_key {
300 let key_path = args.output_archive.with_extension("hmac");
301 fs_write(&key_path, &hmac_key)?;
302 eprintln!("HMAC key written to {}", key_path.display());
303 }
304
305 if let Some(canary_shard) = canary_metadata {
307 let canary_path = args.output_archive.with_extension("canary");
308 let canary_json = serde_json::to_string_pretty(&canary_shard).map_err(|e| {
309 let stego_err = StegoError::MalformedCoverData {
310 reason: format!("Failed to serialize canary metadata: {e}"),
311 };
312 AppError::Canary(CanaryError::EmbedFailed { source: stego_err })
313 })?;
314 fs_write(&canary_path, canary_json.as_bytes())?;
315 eprintln!("Canary metadata written to {}", canary_path.display());
316 }
317
318 eprintln!(
319 "Distributed into {} shards → {}",
320 stego_covers.len(),
321 args.output_archive.display()
322 );
323 Ok(())
324}
325
326fn distribute_covers(
327 args: &cli::EmbedDistributedArgs,
328 payload: &Payload,
329 covers: Vec<CoverMedia>,
330 profile: &crate::domain::types::EmbeddingProfile,
331 embedder: &dyn EmbedTechnique,
332) -> Result<(Vec<CoverMedia>, Option<Vec<u8>>), AppError> {
333 if let Some(manifest_path) = &args.geo_manifest {
334 let manifest = load_geographic_manifest(manifest_path)?;
335 let geo_distributor = crate::adapters::opsec::GeographicDistributorImpl::new();
336 let stego_covers =
337 geo_distributor.distribute_with_manifest(payload, covers, &manifest, embedder)?;
338 return Ok((stego_covers, None));
339 }
340
341 let hmac_key = if let Some(p) = &args.hmac_key {
342 fs_read(p)?
343 } else {
344 crate::adapters::distribution::DistributorImpl::generate_hmac_key()
345 };
346 let generated_hmac_key = if args.hmac_key.is_none() {
347 Some(hmac_key.clone())
348 } else {
349 None
350 };
351 let distributor = crate::adapters::distribution::DistributorImpl::new_with_shard_config(
352 hmac_key,
353 args.data_shards,
354 args.parity_shards,
355 );
356 let stego_covers = crate::application::services::DistributeService::distribute(
357 payload,
358 covers,
359 profile,
360 &distributor,
361 embedder,
362 )?;
363 Ok((stego_covers, generated_hmac_key))
364}
365
366fn load_geographic_manifest(
367 manifest_path: &Path,
368) -> Result<crate::domain::types::GeographicManifest, AppError> {
369 let manifest_raw = fs_read(manifest_path)?;
370 let manifest_str = String::from_utf8(manifest_raw).map_err(|e| OpsecError::ManifestError {
371 reason: format!("manifest is not valid UTF-8: {e}"),
372 })?;
373 toml::from_str(&manifest_str).map_err(|e| {
374 OpsecError::ManifestError {
375 reason: format!("manifest parse failed: {e}"),
376 }
377 .into()
378 })
379}
380
381fn cmd_extract_distributed(args: &cli::ExtractDistributedArgs) -> Result<(), AppError> {
384 let technique = resolve_technique(args.technique);
385 let extractor = build_extractor(technique);
386
387 let archive_bytes = fs_read(&args.input_archive)?;
388 let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
389 let entries = ArchiveService::unpack(
390 &archive_bytes,
391 crate::domain::types::ArchiveFormat::Zip,
392 &handler,
393 )?;
394
395 let covers: Vec<CoverMedia> = entries
396 .iter()
397 .map(|(name, data)| load_cover_from_named_bytes(name, technique, data))
398 .collect::<Result<_, AppError>>()?;
399
400 let hmac_key = if let Some(p) = &args.hmac_key {
401 fs_read(p)?
402 } else {
403 let default_path = args.input_archive.with_extension("hmac");
405 fs_read(&default_path)?
406 };
407 let reconstructor = crate::adapters::reconstruction::ReconstructorImpl::new(
408 args.data_shards,
409 args.parity_shards,
410 hmac_key,
411 0,
412 );
413 let payload = crate::application::services::ReconstructService::reconstruct(
414 covers,
415 extractor.as_ref(),
416 &reconstructor,
417 &|done, total| eprintln!("Reconstructing: {done}/{total}"),
418 )?;
419 fs_write(&args.output, payload.as_bytes())?;
420 eprintln!("Reconstructed {} bytes", payload.len());
421 Ok(())
422}
423
424fn cmd_analyse(args: &cli::AnalyseArgs) -> Result<(), AppError> {
427 let technique = resolve_technique(args.technique);
428 let cover = load_cover_from_path(&args.cover, technique)?;
429 let analyser = crate::adapters::analysis::CapacityAnalyserImpl::new();
430 let report = AnalyseService::analyse(&cover, technique, &analyser)?;
431
432 if args.json {
433 let json = serde_json::to_string_pretty(&report).map_err(|e| {
434 crate::domain::errors::AnalysisError::ComputationFailed {
435 reason: format!("json serialisation: {e}"),
436 }
437 })?;
438 println!("{json}");
439 } else {
440 println!("Technique: {:?}", report.technique);
441 println!("Capacity: {} bytes", report.cover_capacity.bytes);
442 println!("Chi-square: {:.2} dB", report.chi_square_score);
443 println!("Risk: {:?}", report.detectability_risk);
444 println!(
445 "Recommended: {} bytes",
446 report.recommended_max_payload_bytes
447 );
448 }
449 Ok(())
450}
451
452fn cmd_archive(args: &cli::ArchiveArgs) -> Result<(), AppError> {
455 match &args.subcmd {
456 cli::ArchiveSubcommand::Pack {
457 files,
458 format,
459 output,
460 } => {
461 let file_data: Result<Vec<(String, Vec<u8>)>, AppError> = files
462 .iter()
463 .map(|p| {
464 let name = p.file_name().map_or_else(
465 || p.display().to_string(),
466 |n| n.to_string_lossy().into_owned(),
467 );
468 let data = fs_read(p)?;
469 Ok((name, data))
470 })
471 .collect();
472 let file_data = file_data?;
473 let refs: Vec<(&str, &[u8])> = file_data
474 .iter()
475 .map(|(n, d)| (n.as_str(), d.as_slice()))
476 .collect();
477 let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
478 let fmt = resolve_archive_format(*format);
479 let packed = ArchiveService::pack(&refs, fmt, &handler)?;
480 fs_write(output, &packed)?;
481 eprintln!("Packed {} files → {}", files.len(), output.display());
482 }
483 cli::ArchiveSubcommand::Unpack {
484 input,
485 format,
486 output_dir,
487 } => {
488 let data = fs_read(input)?;
489 let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
490 let fmt = resolve_archive_format(*format);
491 let entries = ArchiveService::unpack(&data, fmt, &handler)?;
492 fs_create_dir_all(output_dir)?;
493 for (name, content) in &entries {
494 let path = output_dir.join(name);
495 if let Some(parent) = path.parent() {
496 fs_create_dir_all(parent)?;
497 }
498 fs_write(&path, content.as_ref())?;
499 }
500 eprintln!(
501 "Unpacked {} entries → {}",
502 entries.len(),
503 output_dir.display()
504 );
505 }
506 }
507 Ok(())
508}
509
510fn cmd_scrub(args: &cli::ScrubArgs) -> Result<(), AppError> {
513 let text = std::fs::read_to_string(&args.input).map_err(|e| {
514 crate::domain::errors::ScrubberError::InvalidUtf8 {
515 reason: format!("read: {e}"),
516 }
517 })?;
518 let scrubber = crate::adapters::scrubber::StyloScrubberImpl::new();
519 let profile = crate::domain::types::StyloProfile {
520 normalize_punctuation: true,
521 target_avg_sentence_len: f64::from(args.avg_sentence_len),
522 target_vocab_size: args.vocab_size,
523 };
524 let result = ScrubService::scrub(&text, &profile, &scrubber)?;
525 fs_write(&args.output, result.as_bytes())?;
526 eprintln!("Scrubbed text → {}", args.output.display());
527 Ok(())
528}
529
530fn cmd_dead_drop(args: &cli::DeadDropArgs) -> Result<(), AppError> {
533 let technique = resolve_technique(args.technique);
534 let embedder = build_embedder(technique);
535 let cover = load_cover_from_path(&args.cover, technique)?;
536 let payload_bytes = fs_read(&args.input)?;
537 let payload = Payload::from_bytes(payload_bytes);
538 let platform = resolve_platform(args.platform);
539 let encoder = crate::adapters::deadrop::DeadDropEncoderImpl::new();
540 let stego = crate::application::services::DeadDropService::encode(
541 cover,
542 &payload,
543 &platform,
544 embedder.as_ref(),
545 &encoder,
546 )?;
547 save_cover_to_path(&stego, &args.output)?;
548 eprintln!("Dead drop encoded for {platform:?}");
549 Ok(())
550}
551
552fn cmd_time_lock(args: &cli::TimeLockArgs) -> Result<(), AppError> {
555 let service = crate::adapters::timelock::TimeLockServiceImpl::default();
556 match &args.subcmd {
557 cli::TimeLockSubcommand::Lock {
558 input,
559 unlock_at,
560 output_puzzle,
561 } => {
562 let data = fs_read(input)?;
563 let payload = Payload::from_bytes(data);
564 let ts = chrono::DateTime::parse_from_rfc3339(unlock_at)
565 .map_err(
566 |e| crate::domain::errors::TimeLockError::ComputationFailed {
567 reason: format!("invalid RFC 3339 timestamp: {e}"),
568 },
569 )?
570 .with_timezone(&chrono::Utc);
571 let puzzle =
572 crate::application::services::TimeLockServiceApp::lock(&payload, ts, &service)?;
573 let encoded = serde_json::to_vec_pretty(&puzzle).map_err(|e| {
574 crate::domain::errors::TimeLockError::ComputationFailed {
575 reason: format!("serialize puzzle: {e}"),
576 }
577 })?;
578 fs_write(output_puzzle, &encoded)?;
579 eprintln!("Puzzle → {}", output_puzzle.display());
580 }
581 cli::TimeLockSubcommand::Unlock {
582 puzzle: puzzle_path,
583 output,
584 } => {
585 let data = fs_read(puzzle_path)?;
586 let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
587 .map_err(
588 |e| crate::domain::errors::TimeLockError::ComputationFailed {
589 reason: format!("deserialize puzzle: {e}"),
590 },
591 )?;
592 let payload =
593 crate::application::services::TimeLockServiceApp::unlock(&puzzle, &service)?;
594 fs_write(output, payload.as_bytes())?;
595 eprintln!("Unlocked {} bytes", payload.len());
596 }
597 cli::TimeLockSubcommand::TryUnlock {
598 puzzle: puzzle_path,
599 } => {
600 let data = fs_read(puzzle_path)?;
601 let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
602 .map_err(
603 |e| crate::domain::errors::TimeLockError::ComputationFailed {
604 reason: format!("deserialize puzzle: {e}"),
605 },
606 )?;
607 match crate::application::services::TimeLockServiceApp::try_unlock(&puzzle, &service)? {
608 Some(p) => eprintln!("Puzzle solved: {} bytes", p.len()),
609 None => eprintln!("Puzzle not yet solvable"),
610 }
611 }
612 }
613 Ok(())
614}
615
616fn cmd_watermark(args: &cli::WatermarkArgs) -> Result<(), AppError> {
619 match &args.subcmd {
620 cli::WatermarkSubcommand::EmbedTripwire {
621 cover,
622 output,
623 recipient_id,
624 } => {
625 let cover_media =
626 load_cover_from_path(cover, crate::domain::types::StegoTechnique::LsbImage)?;
627 let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
628 let rid = uuid::Uuid::parse_str(recipient_id).map_err(|e| {
629 crate::domain::errors::OpsecError::WatermarkError {
630 reason: format!("invalid UUID: {e}"),
631 }
632 })?;
633 let tag = crate::domain::types::WatermarkTripwireTag {
634 recipient_id: rid,
635 embedding_seed: recipient_id.as_bytes().to_vec(),
636 };
637 let stego = crate::application::services::ForensicService::embed_tripwire(
638 cover_media,
639 &tag,
640 &watermarker,
641 )?;
642 save_cover_to_path(&stego, output)?;
643 eprintln!("Tripwire embedded for {recipient_id}");
644 }
645 cli::WatermarkSubcommand::Identify { cover, tags } => {
646 let cover_media =
647 load_cover_from_path(cover, crate::domain::types::StegoTechnique::LsbImage)?;
648 let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
649 let tag_list = load_watermark_tags(tags)?;
650 let receipt = crate::application::services::ForensicService::identify_recipient(
651 &cover_media,
652 &tag_list,
653 &watermarker,
654 )?;
655 match receipt {
656 Some(r) => println!("Identified recipient: {r}"),
657 None => println!("No matching watermark found"),
658 }
659 }
660 }
661 Ok(())
662}
663
664fn cmd_corpus(args: &cli::CorpusArgs) -> Result<(), AppError> {
667 use crate::domain::ports::CorpusIndex;
668
669 let index = crate::adapters::corpus::CorpusIndexImpl::new();
670 match &args.subcmd {
671 cli::CorpusSubcommand::Build { dir } => {
672 let count = index.build_index(dir)?;
673 eprintln!("Indexed {count} images from {}", dir.display());
674 }
675 cli::CorpusSubcommand::Search {
676 input,
677 technique,
678 top,
679 } => {
680 let data = fs_read(input)?;
681 let payload = Payload::from_bytes(data);
682 let tech = resolve_technique(*technique);
683 let results = index.search(&payload, tech, *top)?;
684 for entry in &results {
685 println!("{}", entry.path);
686 }
687 }
688 }
689 Ok(())
690}
691
692fn cmd_panic(args: &cli::PanicArgs) -> Result<(), AppError> {
695 let config = crate::domain::types::PanicWipeConfig {
696 key_paths: args.key_paths.iter().map(PathBuf::from).collect(),
697 config_paths: Vec::new(),
698 temp_dirs: Vec::new(),
699 };
700 let wiper = crate::adapters::opsec::SecurePanicWiper::new();
701 crate::application::services::PanicWipeService::wipe(&config, &wiper)?;
702 Ok(())
703}
704
705fn cmd_completions(args: &cli::CompletionsArgs) -> Result<(), AppError> {
708 use clap::CommandFactory;
709 use clap_complete::generate;
710
711 let mut cmd = Cli::command();
712 match &args.output {
713 Some(path) => {
714 let mut file = std::fs::File::create(path).map_err(|e| {
715 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
716 reason: format!("write {}: {e}", path.display()),
717 })
718 })?;
719 generate(args.shell, &mut cmd, "shadowforge", &mut file);
720 }
721 None => {
722 generate(args.shell, &mut cmd, "shadowforge", &mut io::stdout());
723 }
724 }
725 Ok(())
726}
727
728fn fs_read(path: &Path) -> Result<Vec<u8>, AppError> {
733 std::fs::read(path).map_err(|e| {
734 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
735 reason: format!("read {}: {e}", path.display()),
736 })
737 })
738}
739
740fn fs_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
741 if let Some(parent) = path.parent() {
742 fs_create_dir_all(parent)?;
743 }
744 std::fs::write(path, data).map_err(|e| {
745 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
746 reason: format!("write {}: {e}", path.display()),
747 })
748 })
749}
750
751fn fs_create_dir_all(path: &Path) -> Result<(), AppError> {
752 std::fs::create_dir_all(path).map_err(|e| {
753 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
754 reason: format!("mkdir {}: {e}", path.display()),
755 })
756 })
757}
758
759fn map_media_error(error: crate::domain::errors::MediaError) -> AppError {
760 match error {
761 crate::domain::errors::MediaError::UnsupportedFormat { extension } => {
762 AppError::Stego(crate::domain::errors::StegoError::UnsupportedCoverType {
763 reason: format!("unsupported cover format: {extension}"),
764 })
765 }
766 crate::domain::errors::MediaError::DecodeFailed { reason }
767 | crate::domain::errors::MediaError::EncodeFailed { reason }
768 | crate::domain::errors::MediaError::IoError { reason } => {
769 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData { reason })
770 }
771 }
772}
773
774#[cfg(feature = "pdf")]
775fn map_pdf_runner_error(error: crate::domain::errors::PdfError) -> AppError {
776 match error {
777 crate::domain::errors::PdfError::Encrypted => {
778 AppError::Stego(crate::domain::errors::StegoError::UnsupportedCoverType {
779 reason: "encrypted PDF documents are not supported".to_string(),
780 })
781 }
782 crate::domain::errors::PdfError::ParseFailed { reason }
783 | crate::domain::errors::PdfError::RenderFailed { reason, .. }
784 | crate::domain::errors::PdfError::RebuildFailed { reason }
785 | crate::domain::errors::PdfError::EmbedFailed { reason }
786 | crate::domain::errors::PdfError::ExtractFailed { reason }
787 | crate::domain::errors::PdfError::IoError { reason } => {
788 AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData { reason })
789 }
790 }
791}
792
793fn load_cover_from_path(path: &Path, technique: StegoTechnique) -> Result<CoverMedia, AppError> {
794 match technique {
795 StegoTechnique::LsbAudio | StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
796 let loader = crate::adapters::media::AudioMediaLoader;
797 loader.load(path).map_err(map_media_error)
798 }
799 StegoTechnique::PdfContentStream | StegoTechnique::PdfMetadata => load_pdf_cover(path),
800 StegoTechnique::ZeroWidthText => {
801 let data = fs_read(path)?;
802 Ok(load_cover(technique, &data))
803 }
804 StegoTechnique::LsbImage
805 | StegoTechnique::DctJpeg
806 | StegoTechnique::Palette
807 | StegoTechnique::CorpusSelection
808 | StegoTechnique::DualPayload => {
809 let loader = crate::adapters::media::ImageMediaLoader;
810 loader.load(path).map_err(map_media_error)
811 }
812 }
813}
814
815#[cfg(feature = "pdf")]
816fn load_pdf_cover(path: &Path) -> Result<CoverMedia, AppError> {
817 use crate::domain::ports::PdfProcessor;
818
819 let processor = crate::adapters::pdf::PdfProcessorImpl::default();
820 processor.load_pdf(path).map_err(map_pdf_runner_error)
821}
822
823#[cfg(not(feature = "pdf"))]
824fn load_pdf_cover(_path: &Path) -> Result<CoverMedia, AppError> {
825 Err(AppError::Stego(
826 crate::domain::errors::StegoError::UnsupportedCoverType {
827 reason: "PDF support is not enabled in this build".to_string(),
828 },
829 ))
830}
831
832fn save_cover_to_path(media: &CoverMedia, path: &Path) -> Result<(), AppError> {
833 if let Some(parent) = path.parent() {
834 fs_create_dir_all(parent)?;
835 }
836
837 match media.kind {
838 CoverMediaKind::PngImage
839 | CoverMediaKind::BmpImage
840 | CoverMediaKind::JpegImage
841 | CoverMediaKind::GifImage => {
842 let loader = crate::adapters::media::ImageMediaLoader;
843 loader.save(media, path).map_err(map_media_error)
844 }
845 CoverMediaKind::WavAudio => {
846 let loader = crate::adapters::media::AudioMediaLoader;
847 loader.save(media, path).map_err(map_media_error)
848 }
849 CoverMediaKind::PdfDocument => save_pdf_cover(media, path),
850 CoverMediaKind::PlainText => fs_write(path, media.data.as_ref()),
851 }
852}
853
854#[cfg(feature = "pdf")]
855fn save_pdf_cover(media: &CoverMedia, path: &Path) -> Result<(), AppError> {
856 use crate::domain::ports::PdfProcessor;
857
858 let processor = crate::adapters::pdf::PdfProcessorImpl::default();
859 processor
860 .save_pdf(media, path)
861 .map_err(map_pdf_runner_error)
862}
863
864#[cfg(not(feature = "pdf"))]
865fn save_pdf_cover(_media: &CoverMedia, _path: &Path) -> Result<(), AppError> {
866 Err(AppError::Stego(
867 crate::domain::errors::StegoError::UnsupportedCoverType {
868 reason: "PDF support is not enabled in this build".to_string(),
869 },
870 ))
871}
872
873fn serialise_cover_to_bytes(media: &CoverMedia) -> Result<Vec<u8>, AppError> {
874 match media.kind {
875 CoverMediaKind::PdfDocument | CoverMediaKind::PlainText => Ok(media.data.to_vec()),
876 _ => {
877 let temp_path = std::env::temp_dir().join(format!(
878 "shadowforge-{}.{}",
879 uuid::Uuid::new_v4(),
880 cover_file_extension(media.kind)
881 ));
882 let result = (|| {
883 save_cover_to_path(media, &temp_path)?;
884 fs_read(&temp_path)
885 })();
886 let _ = std::fs::remove_file(&temp_path);
887 result
888 }
889 }
890}
891
892fn load_cover_from_named_bytes(
893 name: &str,
894 technique: StegoTechnique,
895 data: &[u8],
896) -> Result<CoverMedia, AppError> {
897 if technique == StegoTechnique::ZeroWidthText {
898 return Ok(load_cover(technique, data));
899 }
900
901 let extension = Path::new(name)
902 .extension()
903 .and_then(|value| value.to_str())
904 .unwrap_or_else(|| technique_file_extension(technique));
905 let temp_path = std::env::temp_dir().join(format!(
906 "shadowforge-{}.{}",
907 uuid::Uuid::new_v4(),
908 extension
909 ));
910 let result = (|| {
911 fs_write(&temp_path, data)?;
912 load_cover_from_path(&temp_path, technique)
913 })();
914 let _ = std::fs::remove_file(&temp_path);
915 result
916}
917
918const fn cover_file_extension(kind: CoverMediaKind) -> &'static str {
919 match kind {
920 CoverMediaKind::PngImage => "png",
921 CoverMediaKind::BmpImage => "bmp",
922 CoverMediaKind::JpegImage => "jpg",
923 CoverMediaKind::GifImage => "gif",
924 CoverMediaKind::WavAudio => "wav",
925 CoverMediaKind::PdfDocument => "pdf",
926 CoverMediaKind::PlainText => "txt",
927 }
928}
929
930const fn technique_file_extension(technique: StegoTechnique) -> &'static str {
931 match technique {
932 StegoTechnique::LsbAudio | StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
933 "wav"
934 }
935 StegoTechnique::PdfContentStream | StegoTechnique::PdfMetadata => "pdf",
936 StegoTechnique::ZeroWidthText => "txt",
937 StegoTechnique::DctJpeg => "jpg",
938 StegoTechnique::LsbImage
939 | StegoTechnique::Palette
940 | StegoTechnique::CorpusSelection
941 | StegoTechnique::DualPayload => "png",
942 }
943}
944
945fn load_cover(technique: crate::domain::types::StegoTechnique, data: &[u8]) -> CoverMedia {
946 let kind = match technique {
947 crate::domain::types::StegoTechnique::LsbAudio
948 | crate::domain::types::StegoTechnique::PhaseEncoding
949 | crate::domain::types::StegoTechnique::EchoHiding => CoverMediaKind::WavAudio,
950 crate::domain::types::StegoTechnique::ZeroWidthText => CoverMediaKind::PlainText,
951 crate::domain::types::StegoTechnique::PdfContentStream
952 | crate::domain::types::StegoTechnique::PdfMetadata => CoverMediaKind::PdfDocument,
953 crate::domain::types::StegoTechnique::DctJpeg => CoverMediaKind::JpegImage,
954 crate::domain::types::StegoTechnique::LsbImage
955 | crate::domain::types::StegoTechnique::Palette
956 | crate::domain::types::StegoTechnique::CorpusSelection
957 | crate::domain::types::StegoTechnique::DualPayload => CoverMediaKind::PngImage,
958 };
959 CoverMedia {
960 kind,
961 data: bytes::Bytes::from(data.to_vec()),
962 metadata: std::collections::HashMap::new(),
963 }
964}
965
966fn build_embedder(technique: crate::domain::types::StegoTechnique) -> Box<dyn EmbedTechnique> {
968 use crate::domain::types::StegoTechnique;
969 match technique {
970 StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
971 StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
972 StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
973 StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
974 StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
975 StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
976 StegoTechnique::PdfContentStream => build_pdf_content_stream_embedder(),
977 StegoTechnique::PdfMetadata => build_pdf_metadata_embedder(),
978 StegoTechnique::CorpusSelection => Box::new(UnsupportedTechnique::new(
979 StegoTechnique::CorpusSelection,
980 "corpus selection must use the corpus workflow, not generic embed",
981 )),
982 StegoTechnique::DualPayload => Box::new(UnsupportedTechnique::new(
983 StegoTechnique::DualPayload,
984 "dual-payload embedding must use the deniable embed workflow",
985 )),
986 StegoTechnique::LsbImage => Box::new(crate::adapters::stego::LsbImage::new()),
987 }
988}
989
990fn build_extractor(technique: crate::domain::types::StegoTechnique) -> Box<dyn ExtractTechnique> {
992 use crate::domain::types::StegoTechnique;
993 match technique {
994 StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
995 StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
996 StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
997 StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
998 StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
999 StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
1000 StegoTechnique::PdfContentStream => build_pdf_content_stream_extractor(),
1001 StegoTechnique::PdfMetadata => build_pdf_metadata_extractor(),
1002 StegoTechnique::CorpusSelection => Box::new(UnsupportedTechnique::new(
1003 StegoTechnique::CorpusSelection,
1004 "corpus selection must use the corpus workflow, not generic extract",
1005 )),
1006 StegoTechnique::DualPayload => Box::new(UnsupportedTechnique::new(
1007 StegoTechnique::DualPayload,
1008 "dual-payload extraction must use the deniable extraction workflow",
1009 )),
1010 StegoTechnique::LsbImage => Box::new(crate::adapters::stego::LsbImage::new()),
1011 }
1012}
1013
1014#[cfg(feature = "pdf")]
1015fn build_pdf_content_stream_embedder() -> Box<dyn EmbedTechnique> {
1016 Box::new(crate::adapters::pdf::PdfContentStreamStego::new())
1017}
1018
1019#[cfg(not(feature = "pdf"))]
1020fn build_pdf_content_stream_embedder() -> Box<dyn EmbedTechnique> {
1021 Box::new(UnsupportedTechnique::new(
1022 crate::domain::types::StegoTechnique::PdfContentStream,
1023 "PDF support is not enabled in this build",
1024 ))
1025}
1026
1027#[cfg(feature = "pdf")]
1028fn build_pdf_metadata_embedder() -> Box<dyn EmbedTechnique> {
1029 Box::new(crate::adapters::pdf::PdfMetadataStego::new())
1030}
1031
1032#[cfg(not(feature = "pdf"))]
1033fn build_pdf_metadata_embedder() -> Box<dyn EmbedTechnique> {
1034 Box::new(UnsupportedTechnique::new(
1035 crate::domain::types::StegoTechnique::PdfMetadata,
1036 "PDF support is not enabled in this build",
1037 ))
1038}
1039
1040#[cfg(feature = "pdf")]
1041fn build_pdf_content_stream_extractor() -> Box<dyn ExtractTechnique> {
1042 Box::new(crate::adapters::pdf::PdfContentStreamStego::new())
1043}
1044
1045#[cfg(not(feature = "pdf"))]
1046fn build_pdf_content_stream_extractor() -> Box<dyn ExtractTechnique> {
1047 Box::new(UnsupportedTechnique::new(
1048 crate::domain::types::StegoTechnique::PdfContentStream,
1049 "PDF support is not enabled in this build",
1050 ))
1051}
1052
1053#[cfg(feature = "pdf")]
1054fn build_pdf_metadata_extractor() -> Box<dyn ExtractTechnique> {
1055 Box::new(crate::adapters::pdf::PdfMetadataStego::new())
1056}
1057
1058#[cfg(not(feature = "pdf"))]
1059fn build_pdf_metadata_extractor() -> Box<dyn ExtractTechnique> {
1060 Box::new(UnsupportedTechnique::new(
1061 crate::domain::types::StegoTechnique::PdfMetadata,
1062 "PDF support is not enabled in this build",
1063 ))
1064}
1065
1066#[derive(Debug)]
1067struct UnsupportedTechnique {
1068 technique: crate::domain::types::StegoTechnique,
1069 reason: &'static str,
1070}
1071
1072impl UnsupportedTechnique {
1073 const fn new(technique: crate::domain::types::StegoTechnique, reason: &'static str) -> Self {
1074 Self { technique, reason }
1075 }
1076}
1077
1078impl EmbedTechnique for UnsupportedTechnique {
1079 fn technique(&self) -> crate::domain::types::StegoTechnique {
1080 self.technique
1081 }
1082
1083 fn capacity(&self, _cover: &CoverMedia) -> Result<crate::domain::types::Capacity, StegoError> {
1084 Err(StegoError::UnsupportedCoverType {
1085 reason: self.reason.to_string(),
1086 })
1087 }
1088
1089 fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
1090 Err(StegoError::UnsupportedCoverType {
1091 reason: self.reason.to_string(),
1092 })
1093 }
1094}
1095
1096impl ExtractTechnique for UnsupportedTechnique {
1097 fn technique(&self) -> crate::domain::types::StegoTechnique {
1098 self.technique
1099 }
1100
1101 fn extract(&self, _stego: &CoverMedia) -> Result<Payload, StegoError> {
1102 Err(StegoError::UnsupportedCoverType {
1103 reason: self.reason.to_string(),
1104 })
1105 }
1106}
1107
1108const fn resolve_technique(t: cli::Technique) -> crate::domain::types::StegoTechnique {
1109 match t {
1110 cli::Technique::Lsb => crate::domain::types::StegoTechnique::LsbImage,
1111 cli::Technique::Dct => crate::domain::types::StegoTechnique::DctJpeg,
1112 cli::Technique::Palette => crate::domain::types::StegoTechnique::Palette,
1113 cli::Technique::LsbAudio => crate::domain::types::StegoTechnique::LsbAudio,
1114 cli::Technique::Phase => crate::domain::types::StegoTechnique::PhaseEncoding,
1115 cli::Technique::Echo => crate::domain::types::StegoTechnique::EchoHiding,
1116 cli::Technique::ZeroWidth => crate::domain::types::StegoTechnique::ZeroWidthText,
1117 cli::Technique::PdfStream => crate::domain::types::StegoTechnique::PdfContentStream,
1118 cli::Technique::PdfMeta => crate::domain::types::StegoTechnique::PdfMetadata,
1119 cli::Technique::Corpus => crate::domain::types::StegoTechnique::CorpusSelection,
1120 }
1121}
1122
1123fn resolve_profile(
1124 profile: cli::Profile,
1125 platform: Option<cli::Platform>,
1126) -> crate::domain::types::EmbeddingProfile {
1127 match profile {
1128 cli::Profile::Standard => crate::domain::types::EmbeddingProfile::Standard,
1129 cli::Profile::Adaptive => crate::domain::types::EmbeddingProfile::Adaptive {
1130 max_detectability_db: -12.0,
1131 },
1132 cli::Profile::Survivable => {
1133 let p = platform.map_or(crate::domain::types::PlatformProfile::Instagram, |pl| {
1134 resolve_platform(pl)
1135 });
1136 crate::domain::types::EmbeddingProfile::CompressionSurvivable { platform: p }
1137 }
1138 }
1139}
1140
1141const fn resolve_platform(p: cli::Platform) -> crate::domain::types::PlatformProfile {
1142 match p {
1143 cli::Platform::Instagram => crate::domain::types::PlatformProfile::Instagram,
1144 cli::Platform::Twitter => crate::domain::types::PlatformProfile::Twitter,
1145 cli::Platform::Whatsapp => crate::domain::types::PlatformProfile::WhatsApp,
1146 cli::Platform::Telegram => crate::domain::types::PlatformProfile::Telegram,
1147 cli::Platform::Imgur => crate::domain::types::PlatformProfile::Imgur,
1148 }
1149}
1150
1151const fn resolve_archive_format(f: cli::ArchiveFormat) -> crate::domain::types::ArchiveFormat {
1152 match f {
1153 cli::ArchiveFormat::Zip => crate::domain::types::ArchiveFormat::Zip,
1154 cli::ArchiveFormat::Tar => crate::domain::types::ArchiveFormat::Tar,
1155 cli::ArchiveFormat::TarGz => crate::domain::types::ArchiveFormat::TarGz,
1156 }
1157}
1158
1159fn collect_cover_paths(pattern: &str) -> Result<Vec<PathBuf>, AppError> {
1162 let path = PathBuf::from(pattern);
1163 let paths: Vec<PathBuf> = if path.is_dir() {
1164 std::fs::read_dir(&path)
1165 .map_err(
1166 |_| crate::domain::errors::DistributionError::InsufficientCovers {
1167 needed: 1,
1168 got: 0,
1169 },
1170 )?
1171 .filter_map(Result::ok)
1172 .map(|e| e.path())
1173 .filter(|p| p.is_file())
1174 .collect()
1175 } else if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
1176 glob::glob(pattern)
1177 .map_err(
1178 |_| crate::domain::errors::DistributionError::InsufficientCovers {
1179 needed: 1,
1180 got: 0,
1181 },
1182 )?
1183 .filter_map(Result::ok)
1184 .filter(|p| p.is_file())
1185 .collect()
1186 } else {
1187 vec![path]
1189 };
1190
1191 if paths.is_empty() {
1192 return Err(
1193 crate::domain::errors::DistributionError::InsufficientCovers { needed: 1, got: 0 }
1194 .into(),
1195 );
1196 }
1197 Ok(paths)
1198}
1199
1200fn load_watermark_tags(
1201 dir: &Path,
1202) -> Result<Vec<crate::domain::types::WatermarkTripwireTag>, AppError> {
1203 let mut tags = Vec::new();
1204 if dir.is_dir() {
1205 let entries = std::fs::read_dir(dir).map_err(|e| {
1206 crate::domain::errors::OpsecError::WatermarkError {
1207 reason: format!("read dir: {e}"),
1208 }
1209 })?;
1210 for entry in entries {
1211 let entry = entry.map_err(|e: std::io::Error| {
1212 crate::domain::errors::OpsecError::WatermarkError {
1213 reason: format!("dir entry: {e}"),
1214 }
1215 })?;
1216 let path = entry.path();
1217 if path.is_file() {
1218 if let Ok(content) = std::fs::read_to_string(&path) {
1220 let id_str = content.trim();
1221 let rid = uuid::Uuid::parse_str(id_str).map_err(|e| {
1222 crate::domain::errors::OpsecError::WatermarkError {
1223 reason: format!("invalid UUID in {}: {e}", path.display()),
1224 }
1225 })?;
1226 tags.push(crate::domain::types::WatermarkTripwireTag {
1227 recipient_id: rid,
1228 embedding_seed: id_str.as_bytes().to_vec(),
1229 });
1230 }
1231 }
1232 }
1233 }
1234 Ok(tags)
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::{
1240 build_embedder, build_extractor, cmd_extract, load_cover, load_cover_from_path,
1241 save_cover_to_path,
1242 };
1243 use crate::application::services::DeniableEmbedService;
1244 use crate::domain::types::{CoverMediaKind, StegoTechnique};
1245 use crate::interface::cli;
1246 use image::{DynamicImage, Rgba, RgbaImage};
1247 use std::fs;
1248 use tempfile::tempdir;
1249
1250 type TestResult = Result<(), Box<dyn std::error::Error>>;
1251
1252 #[test]
1253 fn dct_cover_is_classified_as_jpeg() {
1254 let cover = load_cover(StegoTechnique::DctJpeg, b"jpeg");
1255 assert_eq!(cover.kind, CoverMediaKind::JpegImage);
1256 }
1257
1258 #[test]
1259 fn pdf_cover_is_classified_as_pdf() {
1260 let cover = load_cover(StegoTechnique::PdfContentStream, b"%PDF-1.7");
1261 assert_eq!(cover.kind, CoverMediaKind::PdfDocument);
1262 }
1263
1264 #[test]
1265 fn pdf_content_stream_embedder_uses_pdf_technique() {
1266 let embedder = build_embedder(StegoTechnique::PdfContentStream);
1267 assert_eq!(embedder.technique(), StegoTechnique::PdfContentStream);
1268 }
1269
1270 #[test]
1271 fn pdf_metadata_extractor_uses_pdf_technique() {
1272 let extractor = build_extractor(StegoTechnique::PdfMetadata);
1273 assert_eq!(extractor.technique(), StegoTechnique::PdfMetadata);
1274 }
1275
1276 #[test]
1277 fn corpus_embedder_is_not_rewritten_as_lsb_image() {
1278 let embedder = build_embedder(StegoTechnique::CorpusSelection);
1279 assert_eq!(embedder.technique(), StegoTechnique::CorpusSelection);
1280 }
1281
1282 #[test]
1283 fn file_backed_image_covers_use_media_loader() -> TestResult {
1284 let dir = tempdir()?;
1285 let input_path = dir.path().join("cover.png");
1286 let output_path = dir.path().join("roundtrip.png");
1287
1288 let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(3, 2, Rgba([1, 2, 3, 255])));
1289 image.save(&input_path)?;
1290
1291 let cover = load_cover_from_path(&input_path, StegoTechnique::LsbImage)?;
1292 assert_eq!(cover.kind, CoverMediaKind::PngImage);
1293 assert_eq!(cover.metadata.get("width"), Some(&"3".to_string()));
1294 assert_eq!(cover.metadata.get("height"), Some(&"2".to_string()));
1295
1296 save_cover_to_path(&cover, &output_path)?;
1297 let written = fs::read(output_path)?;
1298 assert!(!written.is_empty());
1299 Ok(())
1300 }
1301
1302 #[test]
1303 fn extract_uses_deniable_key_path_when_provided() -> TestResult {
1304 let dir = tempdir()?;
1305 let cover_path = dir.path().join("cover.png");
1306 let input_path = dir.path().join("input.png");
1307 let output_path = dir.path().join("output.bin");
1308 let key_path = dir.path().join("primary.key");
1309
1310 let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(40, 40, Rgba([0, 0, 0, 255])));
1311 image.save(&cover_path)?;
1312
1313 let primary_key = vec![7u8; 32];
1314 let decoy_key = vec![9u8; 32];
1315 let pair = crate::domain::types::DeniablePayloadPair {
1316 real_payload: b"real secret".to_vec(),
1317 decoy_payload: b"decoy".to_vec(),
1318 };
1319 let keys = crate::domain::types::DeniableKeySet {
1320 primary_key: primary_key.clone(),
1321 decoy_key,
1322 };
1323 let cover = load_cover_from_path(&cover_path, StegoTechnique::LsbImage)?;
1324 let deniable = crate::adapters::stego::DualPayloadEmbedder;
1325 let embedder = build_embedder(StegoTechnique::LsbImage);
1326 let stego =
1327 DeniableEmbedService::embed_dual(cover, &pair, &keys, embedder.as_ref(), &deniable)?;
1328
1329 save_cover_to_path(&stego, &input_path)?;
1330 fs::write(&key_path, &primary_key)?;
1331
1332 let args = cli::ExtractArgs {
1333 input: input_path,
1334 output: output_path.clone(),
1335 technique: cli::Technique::Lsb,
1336 key: Some(key_path),
1337 amnesia: false,
1338 };
1339
1340 cmd_extract(&args)?;
1341
1342 let extracted = fs::read(output_path)?;
1343 assert_eq!(extracted, b"real secret");
1344 Ok(())
1345 }
1346}