Skip to main content

shadowforge_lib/interface/
cli.rs

1//! CLI command definitions — clap derive API.
2
3use std::path::PathBuf;
4
5use clap::{Parser, Subcommand, ValueEnum};
6use clap_complete::Shell;
7
8/// shadowforge — quantum-resistant steganography toolkit.
9#[derive(Parser, Debug)]
10#[command(
11    name = "shadowforge",
12    version,
13    about = "Quantum-resistant steganography toolkit",
14    long_about = None,
15    propagate_version = true
16)]
17pub struct Cli {
18    /// Subcommand to execute.
19    #[command(subcommand)]
20    pub command: Commands,
21}
22
23/// Top-level subcommands.
24#[derive(Subcommand, Debug)]
25pub enum Commands {
26    /// Print version and git SHA.
27    Version,
28
29    /// Key generation and signing operations.
30    Keygen(KeygenArgs),
31
32    /// Embed a payload into a cover medium.
33    Embed(EmbedArgs),
34
35    /// Extract a hidden payload from a stego cover.
36    Extract(ExtractArgs),
37
38    /// Distribute a payload across multiple covers.
39    #[command(name = "embed-distributed")]
40    EmbedDistributed(EmbedDistributedArgs),
41
42    /// Reconstruct a payload from distributed stego covers.
43    #[command(name = "extract-distributed")]
44    ExtractDistributed(ExtractDistributedArgs),
45
46    /// Analyse a cover for capacity and detectability.
47    #[command(name = "analyse")]
48    Analyse(AnalyseArgs),
49
50    /// Pack/unpack archive bundles.
51    Archive(ArchiveArgs),
52
53    /// Scrub text of stylometric fingerprints.
54    Scrub(ScrubArgs),
55
56    /// Dead drop: encode payload for public platform posting.
57    #[command(name = "dead-drop")]
58    DeadDrop(DeadDropArgs),
59
60    /// Time-lock puzzle operations.
61    #[command(name = "time-lock")]
62    TimeLock(TimeLockArgs),
63
64    /// Forensic watermark operations.
65    Watermark(WatermarkArgs),
66
67    /// Corpus index operations.
68    Corpus(CorpusArgs),
69
70    /// Emergency wipe (hidden).
71    #[command(hide = true)]
72    Panic(PanicArgs),
73
74    /// Generate shell completions.
75    Completions(CompletionsArgs),
76
77    /// Symmetric cipher operations (AES-256-GCM).
78    Cipher(CipherArgs),
79}
80
81// ─── Value enums ──────────────────────────────────────────────────────────────
82
83/// Key algorithm.
84#[derive(Debug, Clone, Copy, ValueEnum)]
85pub enum Algorithm {
86    /// ML-KEM-1024 key encapsulation.
87    Kyber1024,
88    /// ML-DSA-87 digital signature.
89    Dilithium3,
90}
91
92/// Steganographic technique.
93#[derive(Debug, Clone, Copy, ValueEnum)]
94pub enum Technique {
95    /// LSB substitution in PNG/BMP images.
96    Lsb,
97    /// DCT coefficient modulation in JPEG.
98    Dct,
99    /// Palette index substitution for indexed images.
100    Palette,
101    /// LSB substitution in WAV audio.
102    #[value(name = "lsb-audio")]
103    LsbAudio,
104    /// Phase encoding in WAV audio.
105    Phase,
106    /// Echo hiding in WAV audio.
107    Echo,
108    /// Zero-width Unicode characters in text.
109    #[value(name = "zero-width")]
110    ZeroWidth,
111    /// PDF content stream LSB.
112    #[value(name = "pdf-stream")]
113    PdfStream,
114    /// PDF metadata embedding.
115    #[value(name = "pdf-meta")]
116    PdfMeta,
117    /// Corpus-based zero-modification selection.
118    Corpus,
119}
120
121/// Embedding profile.
122#[derive(Debug, Clone, Copy, ValueEnum)]
123pub enum Profile {
124    /// Default — no detectability constraint.
125    Standard,
126    /// Adaptive — bounded detectability budget.
127    Adaptive,
128    /// Compression-survivable for a target platform.
129    Survivable,
130}
131
132/// Target platform.
133#[derive(Debug, Clone, Copy, ValueEnum)]
134pub enum Platform {
135    /// Instagram JPEG recompression.
136    Instagram,
137    /// Twitter/X JPEG recompression.
138    Twitter,
139    /// `WhatsApp` JPEG recompression.
140    Whatsapp,
141    /// Telegram JPEG recompression.
142    Telegram,
143    /// Imgur JPEG recompression.
144    Imgur,
145}
146
147/// Archive format.
148#[derive(Debug, Clone, Copy, ValueEnum)]
149pub enum ArchiveFormat {
150    /// ZIP archive.
151    Zip,
152    /// TAR archive.
153    Tar,
154    /// Gzipped TAR archive.
155    #[value(name = "tar-gz")]
156    TarGz,
157}
158
159// ─── Subcommand argument structs ──────────────────────────────────────────────
160
161/// Arguments for `keygen`.
162#[derive(Parser, Debug)]
163pub struct KeygenArgs {
164    /// Keygen sub-operation.
165    #[command(subcommand)]
166    pub subcmd: Option<KeygenSubcommand>,
167    /// Algorithm to use.
168    #[arg(long, value_enum)]
169    pub algorithm: Option<Algorithm>,
170    /// Output directory for key files.
171    #[arg(long)]
172    pub output: Option<PathBuf>,
173}
174
175/// `keygen` sub-operations.
176#[derive(Subcommand, Debug)]
177pub enum KeygenSubcommand {
178    /// Sign an input file with an ML-DSA secret key.
179    Sign {
180        /// Input file to sign.
181        #[arg(long)]
182        input: PathBuf,
183        /// Secret signing key file.
184        #[arg(long)]
185        secret_key: PathBuf,
186        /// Output detached signature file.
187        #[arg(long)]
188        output: PathBuf,
189    },
190    /// Verify a detached signature with an ML-DSA public key.
191    Verify {
192        /// Signed input file.
193        #[arg(long)]
194        input: PathBuf,
195        /// Public verification key file.
196        #[arg(long)]
197        public_key: PathBuf,
198        /// Detached signature file.
199        #[arg(long)]
200        signature: PathBuf,
201    },
202}
203
204/// Arguments for `embed`.
205#[derive(Parser, Debug)]
206pub struct EmbedArgs {
207    /// Path to the payload file.
208    #[arg(long)]
209    pub input: PathBuf,
210    /// Path to the cover medium (omit for `--amnesia`).
211    #[arg(long, required_unless_present = "amnesia", conflicts_with = "amnesia")]
212    pub cover: Option<PathBuf>,
213    /// Output path for the stego file.
214    #[arg(long, required_unless_present = "amnesia", conflicts_with = "amnesia")]
215    pub output: Option<PathBuf>,
216    /// Steganographic technique.
217    #[arg(long, value_enum)]
218    pub technique: Technique,
219    /// Embedding profile.
220    #[arg(long, value_enum, default_value = "standard")]
221    pub profile: Profile,
222    /// Target platform (required when profile = survivable).
223    #[arg(long, value_enum, required_if_eq("profile", "survivable"))]
224    pub platform: Option<Platform>,
225    /// Amnesiac mode: read cover from stdin, write stego to stdout.
226    #[arg(long)]
227    pub amnesia: bool,
228    /// Scrub text payload before embedding.
229    #[arg(long)]
230    pub scrub_style: bool,
231    /// Enable deniable dual-payload embedding.
232    #[arg(long, conflicts_with = "amnesia")]
233    pub deniable: bool,
234    /// Decoy payload path (used with `--deniable`).
235    #[arg(long, required_if_eq("deniable", "true"))]
236    pub decoy_payload: Option<PathBuf>,
237    /// Decoy key path (used with `--deniable`).
238    #[arg(long, requires = "deniable")]
239    pub decoy_key: Option<PathBuf>,
240    /// Primary key path (used with `--deniable`).
241    #[arg(long, requires = "deniable")]
242    pub key: Option<PathBuf>,
243}
244
245/// Arguments for `extract`.
246#[derive(Parser, Debug)]
247pub struct ExtractArgs {
248    /// Path to the stego file (omit for `--amnesia`).
249    #[arg(long, required_unless_present = "amnesia", conflicts_with = "amnesia")]
250    pub input: Option<PathBuf>,
251    /// Output path for the extracted payload.
252    #[arg(long, required_unless_present = "amnesia", conflicts_with = "amnesia")]
253    pub output: Option<PathBuf>,
254    /// Steganographic technique.
255    #[arg(long, value_enum)]
256    pub technique: Technique,
257    /// Key path for deniable extraction.
258    #[arg(long)]
259    pub key: Option<PathBuf>,
260    /// Amnesiac mode: read from stdin, write to stdout.
261    #[arg(long)]
262    pub amnesia: bool,
263}
264
265/// Arguments for `embed-distributed`.
266#[derive(Parser, Debug)]
267pub struct EmbedDistributedArgs {
268    /// Path to the payload file.
269    #[arg(long)]
270    pub input: PathBuf,
271    /// Glob pattern matching cover files.
272    #[arg(long)]
273    pub covers: String,
274    /// Number of data shards.
275    #[arg(long, default_value = "3")]
276    pub data_shards: u8,
277    /// Number of parity shards.
278    #[arg(long, default_value = "2")]
279    pub parity_shards: u8,
280    /// Output archive path.
281    #[arg(long)]
282    pub output_archive: PathBuf,
283    /// Steganographic technique.
284    #[arg(long, value_enum)]
285    pub technique: Technique,
286    /// Embedding profile.
287    #[arg(long, value_enum, default_value = "standard")]
288    pub profile: Profile,
289    /// Target platform (when profile = survivable).
290    #[arg(long, value_enum)]
291    pub platform: Option<Platform>,
292    /// Inject a canary shard.
293    #[arg(long)]
294    pub canary: bool,
295    /// Geographic manifest TOML path.
296    #[arg(long)]
297    pub geo_manifest: Option<PathBuf>,
298    /// Path to a 32-byte HMAC key for shard integrity. If omitted, a random
299    /// key is generated and written next to the output archive.
300    #[arg(long)]
301    pub hmac_key: Option<PathBuf>,
302}
303
304/// Arguments for `extract-distributed`.
305#[derive(Parser, Debug)]
306pub struct ExtractDistributedArgs {
307    /// Input archive or directory path.
308    #[arg(long)]
309    pub input_archive: PathBuf,
310    /// Output path for the recovered payload.
311    #[arg(long)]
312    pub output: PathBuf,
313    /// Steganographic technique.
314    #[arg(long, value_enum)]
315    pub technique: Technique,
316    /// Number of data shards in the original distribution.
317    #[arg(long, default_value = "3")]
318    pub data_shards: u8,
319    /// Number of parity shards in the original distribution.
320    #[arg(long, default_value = "2")]
321    pub parity_shards: u8,
322    /// Path to the 32-byte HMAC key used during distribution.
323    #[arg(long)]
324    pub hmac_key: Option<PathBuf>,
325}
326
327/// Arguments for `analyse`.
328#[derive(Parser, Debug)]
329pub struct AnalyseArgs {
330    /// Path to the cover file.
331    #[arg(long)]
332    pub cover: PathBuf,
333    /// Steganographic technique.
334    #[arg(long, value_enum)]
335    pub technique: Technique,
336    /// Output as JSON instead of a table.
337    #[arg(long)]
338    pub json: bool,
339}
340
341/// Arguments for `archive`.
342#[derive(Parser, Debug)]
343pub struct ArchiveArgs {
344    /// Archive sub-operation.
345    #[command(subcommand)]
346    pub subcmd: ArchiveSubcommand,
347}
348
349/// Archive sub-operations.
350#[derive(Subcommand, Debug)]
351pub enum ArchiveSubcommand {
352    /// Pack files into an archive.
353    Pack {
354        /// Files to include.
355        #[arg(long, num_args = 1..)]
356        files: Vec<PathBuf>,
357        /// Archive format.
358        #[arg(long, value_enum)]
359        format: ArchiveFormat,
360        /// Output archive path.
361        #[arg(long)]
362        output: PathBuf,
363    },
364    /// Unpack an archive.
365    Unpack {
366        /// Input archive path.
367        #[arg(long)]
368        input: PathBuf,
369        /// Archive format.
370        #[arg(long, value_enum)]
371        format: ArchiveFormat,
372        /// Output directory.
373        #[arg(long)]
374        output_dir: PathBuf,
375    },
376}
377
378/// Arguments for `scrub`.
379#[derive(Parser, Debug)]
380pub struct ScrubArgs {
381    /// Input text file.
382    #[arg(long)]
383    pub input: PathBuf,
384    /// Output file.
385    #[arg(long)]
386    pub output: PathBuf,
387    /// Target average sentence length.
388    #[arg(long, default_value = "15")]
389    pub avg_sentence_len: u32,
390    /// Target vocabulary size.
391    #[arg(long, default_value = "1000")]
392    pub vocab_size: usize,
393}
394
395/// Arguments for `dead-drop`.
396#[derive(Parser, Debug)]
397pub struct DeadDropArgs {
398    /// Cover image path.
399    #[arg(long)]
400    pub cover: PathBuf,
401    /// Payload file path.
402    #[arg(long)]
403    pub input: PathBuf,
404    /// Target platform.
405    #[arg(long, value_enum)]
406    pub platform: Platform,
407    /// Output stego file.
408    #[arg(long)]
409    pub output: PathBuf,
410    /// Steganographic technique.
411    #[arg(long, value_enum, default_value = "lsb")]
412    pub technique: Technique,
413}
414
415/// Arguments for `time-lock`.
416#[derive(Parser, Debug)]
417pub struct TimeLockArgs {
418    /// Time-lock sub-operation.
419    #[command(subcommand)]
420    pub subcmd: TimeLockSubcommand,
421}
422
423/// Time-lock sub-operations.
424#[derive(Subcommand, Debug)]
425pub enum TimeLockSubcommand {
426    /// Create a time-lock puzzle.
427    Lock {
428        /// Input payload file.
429        #[arg(long)]
430        input: PathBuf,
431        /// Earliest unlock time (RFC 3339).
432        #[arg(long)]
433        unlock_at: String,
434        /// Output puzzle file.
435        #[arg(long)]
436        output_puzzle: PathBuf,
437    },
438    /// Solve a time-lock puzzle (blocking).
439    Unlock {
440        /// Puzzle file.
441        #[arg(long)]
442        puzzle: PathBuf,
443        /// Output payload file.
444        #[arg(long)]
445        output: PathBuf,
446    },
447    /// Non-blocking check on a puzzle.
448    TryUnlock {
449        /// Puzzle file.
450        #[arg(long)]
451        puzzle: PathBuf,
452    },
453}
454
455/// Arguments for `watermark`.
456#[derive(Parser, Debug)]
457pub struct WatermarkArgs {
458    /// Watermark sub-operation.
459    #[command(subcommand)]
460    pub subcmd: WatermarkSubcommand,
461}
462
463/// Watermark sub-operations.
464#[derive(Subcommand, Debug)]
465pub enum WatermarkSubcommand {
466    /// Embed a forensic tripwire watermark.
467    #[command(name = "embed-tripwire")]
468    EmbedTripwire {
469        /// Cover file path.
470        #[arg(long)]
471        cover: PathBuf,
472        /// Output stego file.
473        #[arg(long)]
474        output: PathBuf,
475        /// Recipient identifier.
476        #[arg(long)]
477        recipient_id: String,
478    },
479    /// Identify which recipient's watermark is present.
480    Identify {
481        /// Stego cover file.
482        #[arg(long)]
483        cover: PathBuf,
484        /// Directory containing tag JSON files.
485        #[arg(long)]
486        tags: PathBuf,
487    },
488}
489
490/// Arguments for `corpus`.
491#[derive(Parser, Debug)]
492pub struct CorpusArgs {
493    /// Corpus sub-operation.
494    #[command(subcommand)]
495    pub subcmd: CorpusSubcommand,
496}
497
498/// Corpus sub-operations.
499#[derive(Subcommand, Debug)]
500pub enum CorpusSubcommand {
501    /// Build a corpus index from a directory.
502    Build {
503        /// Directory to index.
504        #[arg(long)]
505        dir: PathBuf,
506    },
507    /// Search the corpus for matching covers.
508    Search {
509        /// Payload file.
510        #[arg(long)]
511        input: PathBuf,
512        /// Steganographic technique.
513        #[arg(long, value_enum)]
514        technique: Technique,
515        /// Maximum results to return.
516        #[arg(long, default_value = "5")]
517        top: usize,
518        /// Restrict search to covers matching this AI model ID (e.g. "gemini").
519        #[arg(long)]
520        model: Option<String>,
521        /// Cover resolution to match when `--model` is set, in `WIDTHxHEIGHT`
522        /// format (e.g. "1024x1024").  Ignored if `--model` is absent.
523        #[arg(long)]
524        resolution: Option<String>,
525    },
526}
527
528/// Arguments for (hidden) `panic`.
529#[derive(Parser, Debug)]
530pub struct PanicArgs {
531    /// Key-material file paths to wipe.
532    #[arg(long, num_args = 0..)]
533    pub key_paths: Vec<String>,
534}
535
536/// Arguments for `completions`.
537#[derive(Parser, Debug)]
538pub struct CompletionsArgs {
539    /// Shell to generate completions for (bash, zsh, fish, elvish, powershell).
540    #[arg(value_enum)]
541    pub shell: Shell,
542
543    /// Write completions to a file instead of stdout.
544    #[arg(short, long)]
545    pub output: Option<PathBuf>,
546}
547
548/// Arguments for `cipher`.
549#[derive(Parser, Debug)]
550pub struct CipherArgs {
551    /// Cipher sub-operation.
552    #[command(subcommand)]
553    pub subcmd: CipherSubcommand,
554}
555
556/// Cipher sub-operations.
557#[derive(Subcommand, Debug)]
558pub enum CipherSubcommand {
559    /// Encrypt a file with AES-256-GCM. A random 12-byte nonce is generated and
560    /// prepended to the output ciphertext.
561    Encrypt {
562        /// Input plaintext file.
563        #[arg(long)]
564        input: PathBuf,
565        /// 32-byte key file.
566        #[arg(long)]
567        key: PathBuf,
568        /// Output file (nonce ‖ ciphertext).
569        #[arg(long)]
570        output: PathBuf,
571    },
572    /// Decrypt a file encrypted with AES-256-GCM. The nonce is read from the
573    /// first 12 bytes of the input file.
574    Decrypt {
575        /// Input ciphertext file (12-byte nonce in first bytes).
576        #[arg(long)]
577        input: PathBuf,
578        /// 32-byte key file.
579        #[arg(long)]
580        key: PathBuf,
581        /// Output plaintext file.
582        #[arg(long)]
583        output: PathBuf,
584    },
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn cli_parse_version() {
593        let cli = Cli::try_parse_from(["shadowforge", "version"]);
594        assert!(cli.is_ok());
595    }
596
597    #[test]
598    fn cli_parse_keygen() {
599        let cli = Cli::try_parse_from([
600            "shadowforge",
601            "keygen",
602            "--algorithm",
603            "kyber1024",
604            "--output",
605            "/tmp/keys",
606        ]);
607        assert!(cli.is_ok());
608    }
609
610    #[test]
611    fn cli_parse_keygen_sign() {
612        let cli = Cli::try_parse_from([
613            "shadowforge",
614            "keygen",
615            "sign",
616            "--input",
617            "payload.bin",
618            "--secret-key",
619            "secret.key",
620            "--output",
621            "payload.sig",
622        ]);
623        assert!(cli.is_ok());
624    }
625
626    #[test]
627    fn cli_parse_keygen_verify() {
628        let cli = Cli::try_parse_from([
629            "shadowforge",
630            "keygen",
631            "verify",
632            "--input",
633            "payload.bin",
634            "--public-key",
635            "public.key",
636            "--signature",
637            "payload.sig",
638        ]);
639        assert!(cli.is_ok());
640    }
641
642    #[test]
643    fn cli_parse_embed() {
644        let cli = Cli::try_parse_from([
645            "shadowforge",
646            "embed",
647            "--input",
648            "payload.bin",
649            "--cover",
650            "cover.png",
651            "--output",
652            "stego.png",
653            "--technique",
654            "lsb",
655        ]);
656        assert!(cli.is_ok());
657    }
658
659    #[test]
660    fn cli_parse_extract() {
661        let cli = Cli::try_parse_from([
662            "shadowforge",
663            "extract",
664            "--input",
665            "stego.png",
666            "--output",
667            "payload.bin",
668            "--technique",
669            "lsb",
670        ]);
671        assert!(cli.is_ok());
672    }
673
674    #[test]
675    fn cli_parse_analyse_json() {
676        let cli = Cli::try_parse_from([
677            "shadowforge",
678            "analyse",
679            "--cover",
680            "cover.png",
681            "--technique",
682            "lsb",
683            "--json",
684        ]);
685        assert!(cli.is_ok());
686    }
687
688    #[test]
689    fn cli_parse_scrub() {
690        let cli = Cli::try_parse_from([
691            "shadowforge",
692            "scrub",
693            "--input",
694            "text.txt",
695            "--output",
696            "clean.txt",
697        ]);
698        assert!(cli.is_ok());
699    }
700
701    #[test]
702    fn cli_parse_time_lock_lock() {
703        let cli = Cli::try_parse_from([
704            "shadowforge",
705            "time-lock",
706            "lock",
707            "--input",
708            "secret.bin",
709            "--unlock-at",
710            "2025-12-31T00:00:00Z",
711            "--output-puzzle",
712            "puzzle.json",
713        ]);
714        assert!(cli.is_ok());
715    }
716
717    #[test]
718    fn cli_parse_completions() {
719        let cli = Cli::try_parse_from(["shadowforge", "completions", "bash"]);
720        assert!(cli.is_ok());
721    }
722
723    #[test]
724    fn cli_parse_embed_distributed() {
725        let cli = Cli::try_parse_from([
726            "shadowforge",
727            "embed-distributed",
728            "--input",
729            "payload.bin",
730            "--covers",
731            "covers/*.png",
732            "--output-archive",
733            "dist.zip",
734            "--technique",
735            "lsb",
736        ]);
737        assert!(cli.is_ok());
738    }
739
740    #[test]
741    fn cli_panic_hidden() {
742        // Panic command should not appear in help but should parse
743        let cli = Cli::try_parse_from(["shadowforge", "panic"]);
744        assert!(cli.is_ok());
745    }
746
747    #[test]
748    fn version_output_contains_semver() {
749        let version = env!("CARGO_PKG_VERSION");
750        assert!(version.contains('.'), "version should be semver");
751    }
752
753    #[test]
754    fn cli_parse_cipher_encrypt() {
755        let cli = Cli::try_parse_from([
756            "shadowforge",
757            "cipher",
758            "encrypt",
759            "--input",
760            "payload.bin",
761            "--key",
762            "key.bin",
763            "--output",
764            "out.enc",
765        ]);
766        assert!(cli.is_ok());
767    }
768
769    #[test]
770    fn cli_parse_cipher_decrypt() {
771        let cli = Cli::try_parse_from([
772            "shadowforge",
773            "cipher",
774            "decrypt",
775            "--input",
776            "out.enc",
777            "--key",
778            "key.bin",
779            "--output",
780            "recovered.bin",
781        ]);
782        assert!(cli.is_ok());
783    }
784}