use is_terminal::IsTerminal; use itertools::Itertools; use std::{fmt::Display, path::PathBuf}; use clap::{Parser, Subcommand, ValueEnum}; use crate::{ compression::CompressionArg, hash::{parse_hash_input, HashAlg}, }; /// A safe, user-friendly disk imager. #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] #[command(propagate_version = true)] pub struct Args { #[command(subcommand)] pub command: Command, } #[derive(Subcommand, Debug)] pub enum Command { Burn(BurnArgs), } /// Burn an image to a disk. #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] pub struct BurnArgs { /// Input file to burn. #[arg(value_parser = parse_path_exists)] pub input: PathBuf, /// Where to write the output. If not supplied, we will search for possible /// disks and ask you for where you want to burn. #[arg(short)] pub out: Option, /// What compression format the input file is in. /// /// - `auto` will guess based on the file extension. /// /// - `ask` has the same behavior as `auto`, but with a confirmation. /// /// - `none` means no compression. /// /// All other options are compression formats supported by this build of caligula. #[arg(short = 'z', long, default_value = "ask")] pub compression: CompressionArg, /// The hash of the input file. This can be provided in one of several formats: /// /// - `ask` to ask the user for a hash /// /// - `skip` or `none` to not do hash verification /// /// - an SRI-like string with either base16 or base64 in the format of `-` /// (i.e. `sha256-EVSTQN3/azprGF...`) /// /// - just a hash value, and we will guess the algorithm (i.e. `EVSTQN3/azprGF...`) /// /// The following algorithms are supported: md5, sha1, sha224, sha256, sha384, sha512 #[arg( short = 's', long, value_parser = parse_hash_arg, default_value = "ask", help = "The hash of the input file. For more information, see long help (--help)" )] pub hash: HashArg, /// Where to look for the hash of the input file. #[arg(long, value_parser = parse_path_exists)] pub hash_file: Option, /// Is the hash calculated from the raw file, or the compressed file? #[arg(long)] pub hash_of: Option, /// If provided, we will show all disks, removable or not. /// /// If you use this option, please proceed with caution! #[arg(long)] pub show_all_disks: bool, /// If we should run in interactive mode or not. /// /// Note that interactive mode will fail if all required arguments are not /// fully specified. #[arg(long, default_value = "auto")] pub interactive: Interactive, /// If supplied, we will not ask for confirmation before destroying your disk. #[arg(short, long)] pub force: bool, /// If we don't have permissions on the output file, should we try to become root? #[arg(long, default_value = "ask")] pub root: UseSudo, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum HashArg { Ask, Skip, Hash { alg: HashAlg, expected_hash: Vec, }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum HashOf { Raw, Compressed, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Interactive { Auto, Always, Never, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum UseSudo { Ask, Always, Never, } fn parse_path_exists(p: &str) -> Result { let path = PathBuf::from(p); if !path.exists() { return Err("path does not exist".to_string()); } Ok(path) } fn parse_hash_arg(h: &str) -> Result { match h.to_lowercase().as_ref() { "ask" => Ok(HashArg::Ask), "skip" | "none" => Ok(HashArg::Skip), _ => match parse_hash_input(h) { Ok((alg, expected_hash)) => { if alg.len() > 1 { Err(format!( "Ambiguous hash algorithm! Could be one of: {}. Please specify by prepending [alg]- to your hash.", alg.iter().format(", ") )) } else { Ok(HashArg::Hash { alg: alg[0], expected_hash, }) } } Err(e) => Err(format!("{e}")), }, } } impl Display for HashOf { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { HashOf::Raw => write!(f, "raw"), HashOf::Compressed => write!(f, "compressed"), } } } impl Interactive { pub fn is_interactive(&self) -> bool { match self { Interactive::Auto => std::io::stdin().is_terminal(), Interactive::Always => true, Interactive::Never => false, } } } #[cfg(test)] mod tests { use base64::Engine; use crate::hash::HashAlg; use super::{parse_hash_arg, HashArg}; use test_case::test_case; #[test] fn parse_valid_hash() { let result = parse_hash_arg( "sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC", ) .unwrap(); assert_eq!( result, HashArg::Hash { alg: HashAlg::Sha384, expected_hash: base64::engine::general_purpose::STANDARD .decode("EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC") .unwrap() } ) } #[test_case("skip")] #[test_case("none")] #[test_case("NONE"; "caps")] #[test_case("SkIp"; "mixed case")] fn parse_valid_skip(input: &str) { let result = parse_hash_arg(input).unwrap(); assert_eq!(result, HashArg::Skip); } #[test_case("ask")] #[test_case("ASK"; "caps")] #[test_case("asK"; "mixed case")] fn parse_valid_ask(input: &str) { let result = parse_hash_arg(input).unwrap(); assert_eq!(result, HashArg::Ask); } #[test_case("asdf-fdsu"; "bad algo")] #[test_case("sha256-vf798"; "bad length")] #[test_case("sha256-"; "no hash")] #[test_case(""; "empty")] #[test_case("f9od:fd"; "invalid chars")] fn parse_invalid_hash(input: &str) { parse_hash_arg(input).unwrap_err(); } }