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    /// Generate a key pair.
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
78// ─── Value enums ──────────────────────────────────────────────────────────────
79
80/// Key algorithm.
81#[derive(Debug, Clone, Copy, ValueEnum)]
82pub enum Algorithm {
83    /// ML-KEM-1024 key encapsulation.
84    Kyber1024,
85    /// ML-DSA-87 digital signature.
86    Dilithium3,
87}
88
89/// Steganographic technique.
90#[derive(Debug, Clone, Copy, ValueEnum)]
91pub enum Technique {
92    /// LSB substitution in PNG/BMP images.
93    Lsb,
94    /// DCT coefficient modulation in JPEG.
95    Dct,
96    /// Palette index substitution for indexed images.
97    Palette,
98    /// LSB substitution in WAV audio.
99    #[value(name = "lsb-audio")]
100    LsbAudio,
101    /// Phase encoding in WAV audio.
102    Phase,
103    /// Echo hiding in WAV audio.
104    Echo,
105    /// Zero-width Unicode characters in text.
106    #[value(name = "zero-width")]
107    ZeroWidth,
108    /// PDF content stream LSB.
109    #[value(name = "pdf-stream")]
110    PdfStream,
111    /// PDF metadata embedding.
112    #[value(name = "pdf-meta")]
113    PdfMeta,
114    /// Corpus-based zero-modification selection.
115    Corpus,
116}
117
118/// Embedding profile.
119#[derive(Debug, Clone, Copy, ValueEnum)]
120pub enum Profile {
121    /// Default — no detectability constraint.
122    Standard,
123    /// Adaptive — bounded detectability budget.
124    Adaptive,
125    /// Compression-survivable for a target platform.
126    Survivable,
127}
128
129/// Target platform.
130#[derive(Debug, Clone, Copy, ValueEnum)]
131pub enum Platform {
132    /// Instagram JPEG recompression.
133    Instagram,
134    /// Twitter/X JPEG recompression.
135    Twitter,
136    /// `WhatsApp` JPEG recompression.
137    Whatsapp,
138    /// Telegram JPEG recompression.
139    Telegram,
140    /// Imgur JPEG recompression.
141    Imgur,
142}
143
144/// Archive format.
145#[derive(Debug, Clone, Copy, ValueEnum)]
146pub enum ArchiveFormat {
147    /// ZIP archive.
148    Zip,
149    /// TAR archive.
150    Tar,
151    /// Gzipped TAR archive.
152    #[value(name = "tar-gz")]
153    TarGz,
154}
155
156// ─── Subcommand argument structs ──────────────────────────────────────────────
157
158/// Arguments for `keygen`.
159#[derive(Parser, Debug)]
160pub struct KeygenArgs {
161    /// Algorithm to use.
162    #[arg(long, value_enum)]
163    pub algorithm: Algorithm,
164    /// Output directory for key files.
165    #[arg(long)]
166    pub output: PathBuf,
167}
168
169/// Arguments for `embed`.
170#[derive(Parser, Debug)]
171pub struct EmbedArgs {
172    /// Path to the payload file.
173    #[arg(long)]
174    pub input: PathBuf,
175    /// Path to the cover medium (omit for `--amnesia`).
176    #[arg(long)]
177    pub cover: Option<PathBuf>,
178    /// Output path for the stego file.
179    #[arg(long)]
180    pub output: PathBuf,
181    /// Steganographic technique.
182    #[arg(long, value_enum)]
183    pub technique: Technique,
184    /// Embedding profile.
185    #[arg(long, value_enum, default_value = "standard")]
186    pub profile: Profile,
187    /// Target platform (required when profile = survivable).
188    #[arg(long, value_enum)]
189    pub platform: Option<Platform>,
190    /// Amnesiac mode: read cover from stdin, write stego to stdout.
191    #[arg(long)]
192    pub amnesia: bool,
193    /// Scrub text payload before embedding.
194    #[arg(long)]
195    pub scrub_style: bool,
196    /// Enable deniable dual-payload embedding.
197    #[arg(long)]
198    pub deniable: bool,
199    /// Decoy payload path (used with `--deniable`).
200    #[arg(long)]
201    pub decoy_payload: Option<PathBuf>,
202    /// Decoy key path (used with `--deniable`).
203    #[arg(long)]
204    pub decoy_key: Option<PathBuf>,
205    /// Primary key path (used with `--deniable`).
206    #[arg(long)]
207    pub key: Option<PathBuf>,
208}
209
210/// Arguments for `extract`.
211#[derive(Parser, Debug)]
212pub struct ExtractArgs {
213    /// Path to the stego file (omit for `--amnesia`).
214    #[arg(long)]
215    pub input: PathBuf,
216    /// Output path for the extracted payload.
217    #[arg(long)]
218    pub output: PathBuf,
219    /// Steganographic technique.
220    #[arg(long, value_enum)]
221    pub technique: Technique,
222    /// Key path for deniable extraction.
223    #[arg(long)]
224    pub key: Option<PathBuf>,
225    /// Amnesiac mode: read from stdin, write to stdout.
226    #[arg(long)]
227    pub amnesia: bool,
228}
229
230/// Arguments for `embed-distributed`.
231#[derive(Parser, Debug)]
232pub struct EmbedDistributedArgs {
233    /// Path to the payload file.
234    #[arg(long)]
235    pub input: PathBuf,
236    /// Glob pattern matching cover files.
237    #[arg(long)]
238    pub covers: String,
239    /// Number of data shards.
240    #[arg(long, default_value = "3")]
241    pub data_shards: u8,
242    /// Number of parity shards.
243    #[arg(long, default_value = "2")]
244    pub parity_shards: u8,
245    /// Output archive path.
246    #[arg(long)]
247    pub output_archive: PathBuf,
248    /// Steganographic technique.
249    #[arg(long, value_enum)]
250    pub technique: Technique,
251    /// Embedding profile.
252    #[arg(long, value_enum, default_value = "standard")]
253    pub profile: Profile,
254    /// Target platform (when profile = survivable).
255    #[arg(long, value_enum)]
256    pub platform: Option<Platform>,
257    /// Inject a canary shard.
258    #[arg(long)]
259    pub canary: bool,
260    /// Geographic manifest TOML path.
261    #[arg(long)]
262    pub geo_manifest: Option<PathBuf>,
263    /// Path to a 32-byte HMAC key for shard integrity. If omitted, a random
264    /// key is generated and written next to the output archive.
265    #[arg(long)]
266    pub hmac_key: Option<PathBuf>,
267}
268
269/// Arguments for `extract-distributed`.
270#[derive(Parser, Debug)]
271pub struct ExtractDistributedArgs {
272    /// Input archive or directory path.
273    #[arg(long)]
274    pub input_archive: PathBuf,
275    /// Output path for the recovered payload.
276    #[arg(long)]
277    pub output: PathBuf,
278    /// Steganographic technique.
279    #[arg(long, value_enum)]
280    pub technique: Technique,
281    /// Number of data shards in the original distribution.
282    #[arg(long, default_value = "3")]
283    pub data_shards: u8,
284    /// Number of parity shards in the original distribution.
285    #[arg(long, default_value = "2")]
286    pub parity_shards: u8,
287    /// Path to the 32-byte HMAC key used during distribution.
288    #[arg(long)]
289    pub hmac_key: Option<PathBuf>,
290}
291
292/// Arguments for `analyse`.
293#[derive(Parser, Debug)]
294pub struct AnalyseArgs {
295    /// Path to the cover file.
296    #[arg(long)]
297    pub cover: PathBuf,
298    /// Steganographic technique.
299    #[arg(long, value_enum)]
300    pub technique: Technique,
301    /// Output as JSON instead of a table.
302    #[arg(long)]
303    pub json: bool,
304}
305
306/// Arguments for `archive`.
307#[derive(Parser, Debug)]
308pub struct ArchiveArgs {
309    /// Archive sub-operation.
310    #[command(subcommand)]
311    pub subcmd: ArchiveSubcommand,
312}
313
314/// Archive sub-operations.
315#[derive(Subcommand, Debug)]
316pub enum ArchiveSubcommand {
317    /// Pack files into an archive.
318    Pack {
319        /// Files to include.
320        #[arg(long, num_args = 1..)]
321        files: Vec<PathBuf>,
322        /// Archive format.
323        #[arg(long, value_enum)]
324        format: ArchiveFormat,
325        /// Output archive path.
326        #[arg(long)]
327        output: PathBuf,
328    },
329    /// Unpack an archive.
330    Unpack {
331        /// Input archive path.
332        #[arg(long)]
333        input: PathBuf,
334        /// Archive format.
335        #[arg(long, value_enum)]
336        format: ArchiveFormat,
337        /// Output directory.
338        #[arg(long)]
339        output_dir: PathBuf,
340    },
341}
342
343/// Arguments for `scrub`.
344#[derive(Parser, Debug)]
345pub struct ScrubArgs {
346    /// Input text file.
347    #[arg(long)]
348    pub input: PathBuf,
349    /// Output file.
350    #[arg(long)]
351    pub output: PathBuf,
352    /// Target average sentence length.
353    #[arg(long, default_value = "15")]
354    pub avg_sentence_len: u32,
355    /// Target vocabulary size.
356    #[arg(long, default_value = "1000")]
357    pub vocab_size: usize,
358}
359
360/// Arguments for `dead-drop`.
361#[derive(Parser, Debug)]
362pub struct DeadDropArgs {
363    /// Cover image path.
364    #[arg(long)]
365    pub cover: PathBuf,
366    /// Payload file path.
367    #[arg(long)]
368    pub input: PathBuf,
369    /// Target platform.
370    #[arg(long, value_enum)]
371    pub platform: Platform,
372    /// Output stego file.
373    #[arg(long)]
374    pub output: PathBuf,
375    /// Steganographic technique.
376    #[arg(long, value_enum, default_value = "lsb")]
377    pub technique: Technique,
378}
379
380/// Arguments for `time-lock`.
381#[derive(Parser, Debug)]
382pub struct TimeLockArgs {
383    /// Time-lock sub-operation.
384    #[command(subcommand)]
385    pub subcmd: TimeLockSubcommand,
386}
387
388/// Time-lock sub-operations.
389#[derive(Subcommand, Debug)]
390pub enum TimeLockSubcommand {
391    /// Create a time-lock puzzle.
392    Lock {
393        /// Input payload file.
394        #[arg(long)]
395        input: PathBuf,
396        /// Earliest unlock time (RFC 3339).
397        #[arg(long)]
398        unlock_at: String,
399        /// Output puzzle file.
400        #[arg(long)]
401        output_puzzle: PathBuf,
402    },
403    /// Solve a time-lock puzzle (blocking).
404    Unlock {
405        /// Puzzle file.
406        #[arg(long)]
407        puzzle: PathBuf,
408        /// Output payload file.
409        #[arg(long)]
410        output: PathBuf,
411    },
412    /// Non-blocking check on a puzzle.
413    TryUnlock {
414        /// Puzzle file.
415        #[arg(long)]
416        puzzle: PathBuf,
417    },
418}
419
420/// Arguments for `watermark`.
421#[derive(Parser, Debug)]
422pub struct WatermarkArgs {
423    /// Watermark sub-operation.
424    #[command(subcommand)]
425    pub subcmd: WatermarkSubcommand,
426}
427
428/// Watermark sub-operations.
429#[derive(Subcommand, Debug)]
430pub enum WatermarkSubcommand {
431    /// Embed a forensic tripwire watermark.
432    #[command(name = "embed-tripwire")]
433    EmbedTripwire {
434        /// Cover file path.
435        #[arg(long)]
436        cover: PathBuf,
437        /// Output stego file.
438        #[arg(long)]
439        output: PathBuf,
440        /// Recipient identifier.
441        #[arg(long)]
442        recipient_id: String,
443    },
444    /// Identify which recipient's watermark is present.
445    Identify {
446        /// Stego cover file.
447        #[arg(long)]
448        cover: PathBuf,
449        /// Directory containing tag JSON files.
450        #[arg(long)]
451        tags: PathBuf,
452    },
453}
454
455/// Arguments for `corpus`.
456#[derive(Parser, Debug)]
457pub struct CorpusArgs {
458    /// Corpus sub-operation.
459    #[command(subcommand)]
460    pub subcmd: CorpusSubcommand,
461}
462
463/// Corpus sub-operations.
464#[derive(Subcommand, Debug)]
465pub enum CorpusSubcommand {
466    /// Build a corpus index from a directory.
467    Build {
468        /// Directory to index.
469        #[arg(long)]
470        dir: PathBuf,
471    },
472    /// Search the corpus for matching covers.
473    Search {
474        /// Payload file.
475        #[arg(long)]
476        input: PathBuf,
477        /// Steganographic technique.
478        #[arg(long, value_enum)]
479        technique: Technique,
480        /// Maximum results to return.
481        #[arg(long, default_value = "5")]
482        top: usize,
483    },
484}
485
486/// Arguments for (hidden) `panic`.
487#[derive(Parser, Debug)]
488pub struct PanicArgs {
489    /// Key-material file paths to wipe.
490    #[arg(long, num_args = 0..)]
491    pub key_paths: Vec<String>,
492}
493
494/// Arguments for `completions`.
495#[derive(Parser, Debug)]
496pub struct CompletionsArgs {
497    /// Shell to generate completions for (bash, zsh, fish, elvish, powershell).
498    #[arg(value_enum)]
499    pub shell: Shell,
500
501    /// Write completions to a file instead of stdout.
502    #[arg(short, long)]
503    pub output: Option<PathBuf>,
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn cli_parse_version() {
512        let cli = Cli::try_parse_from(["shadowforge", "version"]);
513        assert!(cli.is_ok());
514    }
515
516    #[test]
517    fn cli_parse_keygen() {
518        let cli = Cli::try_parse_from([
519            "shadowforge",
520            "keygen",
521            "--algorithm",
522            "kyber1024",
523            "--output",
524            "/tmp/keys",
525        ]);
526        assert!(cli.is_ok());
527    }
528
529    #[test]
530    fn cli_parse_embed() {
531        let cli = Cli::try_parse_from([
532            "shadowforge",
533            "embed",
534            "--input",
535            "payload.bin",
536            "--cover",
537            "cover.png",
538            "--output",
539            "stego.png",
540            "--technique",
541            "lsb",
542        ]);
543        assert!(cli.is_ok());
544    }
545
546    #[test]
547    fn cli_parse_extract() {
548        let cli = Cli::try_parse_from([
549            "shadowforge",
550            "extract",
551            "--input",
552            "stego.png",
553            "--output",
554            "payload.bin",
555            "--technique",
556            "lsb",
557        ]);
558        assert!(cli.is_ok());
559    }
560
561    #[test]
562    fn cli_parse_analyse_json() {
563        let cli = Cli::try_parse_from([
564            "shadowforge",
565            "analyse",
566            "--cover",
567            "cover.png",
568            "--technique",
569            "lsb",
570            "--json",
571        ]);
572        assert!(cli.is_ok());
573    }
574
575    #[test]
576    fn cli_parse_scrub() {
577        let cli = Cli::try_parse_from([
578            "shadowforge",
579            "scrub",
580            "--input",
581            "text.txt",
582            "--output",
583            "clean.txt",
584        ]);
585        assert!(cli.is_ok());
586    }
587
588    #[test]
589    fn cli_parse_time_lock_lock() {
590        let cli = Cli::try_parse_from([
591            "shadowforge",
592            "time-lock",
593            "lock",
594            "--input",
595            "secret.bin",
596            "--unlock-at",
597            "2025-12-31T00:00:00Z",
598            "--output-puzzle",
599            "puzzle.json",
600        ]);
601        assert!(cli.is_ok());
602    }
603
604    #[test]
605    fn cli_parse_completions() {
606        let cli = Cli::try_parse_from(["shadowforge", "completions", "bash"]);
607        assert!(cli.is_ok());
608    }
609
610    #[test]
611    fn cli_parse_embed_distributed() {
612        let cli = Cli::try_parse_from([
613            "shadowforge",
614            "embed-distributed",
615            "--input",
616            "payload.bin",
617            "--covers",
618            "covers/*.png",
619            "--output-archive",
620            "dist.zip",
621            "--technique",
622            "lsb",
623        ]);
624        assert!(cli.is_ok());
625    }
626
627    #[test]
628    fn cli_panic_hidden() {
629        // Panic command should not appear in help but should parse
630        let cli = Cli::try_parse_from(["shadowforge", "panic"]);
631        assert!(cli.is_ok());
632    }
633
634    #[test]
635    fn version_output_contains_semver() {
636        let version = env!("CARGO_PKG_VERSION");
637        assert!(version.contains('.'), "version should be semver");
638    }
639}