From 46a5b90d344912c428ab69907f254bfcf3c84384 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Fri, 17 Oct 2025 21:45:17 +0300 Subject: [PATCH] feat(cli): add basic cli feature --- cli/Cargo.toml | 1 + cli/src/args.rs | 214 ++++++++++++++++++++++++++++++++++++++++++++++++ cli/src/main.rs | 30 ++++++- tui/Cargo.toml | 1 + 4 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 cli/src/args.rs diff --git a/cli/Cargo.toml b/cli/Cargo.toml index f70570f..595778f 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" [dependencies] aes = { workspace = true, optional = true } anyhow.workspace = true +cipher-core.workspace = true clap = { version = "4.5", features = ["derive"] } des = { workspace = true, optional = true } thiserror.workspace = true diff --git a/cli/src/args.rs b/cli/src/args.rs new file mode 100644 index 0000000..b46874f --- /dev/null +++ b/cli/src/args.rs @@ -0,0 +1,214 @@ +use clap::{Parser, Subcommand, ValueEnum}; +use std::{ + fmt::{Display, LowerHex, UpperHex}, + fs::read_to_string, + num::IntErrorKind, + path::PathBuf, + str::FromStr, +}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ValueError { + #[error("String contains no content")] + EmptyString, + + #[error("File '{0}' contains no content")] + EmptyFile(PathBuf), + + #[error("Failed to find file '{0}'. File does not exist")] + MissingFile(PathBuf), + + #[error("Failed to read file '{0}'. Cannot read file contents")] + FileReadingError(PathBuf), + + #[error("Invalid number format: {0}")] + InvalidFormat(String), + + #[error("Invalid byte string length: expected no more than, found {0}")] + InvalidByteStringLength(usize), + + #[error("String-to-u64 conversion error: {0}")] + ConversionError(String), +} + +#[derive(Debug, Clone, Parser)] +#[command(version, about, long_about = None)] +pub struct Args { + #[command(subcommand)] + pub operation: Operation, + + /// Key used to encrypt/decrypt data (64-bit number, string, or path to file) + #[arg(short, long, value_parser = Value::from_str, required = true)] + pub key: Value, + + /// The text to encrypt/decrypt data (64-bit number, string, or path to file) + #[arg(value_name = "TEXT", value_parser = Value::from_str, required = true)] + pub text: Value, +} + +#[derive(Debug, Clone, Subcommand, Default)] +pub enum Operation { + /// Encrypt data + #[default] + Encrypt, + /// Decrypt data + Decrypt { + /// Output format for decrypted data + #[arg(short = 'f', long, value_enum)] + output_format: Option, + }, +} + +#[derive(Debug, Clone, Default, ValueEnum)] +pub enum OutputFormat { + /// Binary output + Binary, + /// Octal output (fixed typo) + Octal, + /// Decimal output + Decimal, + /// Hexadecimal output + #[default] + Hex, + /// Text output (ASCII) + Text, +} + +#[derive(Debug, Clone, Copy)] +pub struct Value(u64); + +impl Value { + #[inline] + #[must_use] + pub const fn as_64(self) -> u64 { + self.0 + } + + #[inline] + #[must_use] + pub const fn to_be_bytes(self) -> [u8; 8] { + self.0.to_be_bytes() + } +} + +impl From for u64 { + fn from(value: Value) -> Self { + value.as_64() + } +} + +impl From for Value { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl FromStr for Value { + type Err = ValueError; + fn from_str(s: &str) -> Result { + if let Ok(num) = s.parse::() { + return Ok(Self(num)); + } + + let path = PathBuf::from(s); + if path.exists() && path.is_file() { + if let Ok(contents) = read_to_string(&path) { + let value = parse_string_to_u64(&contents)?; + return Ok(Self(value)); + } + return Err(ValueError::FileReadingError(path)); + } + + let value = parse_string_to_u64(s)?; + Ok(Self(value)) + } +} + +fn parse_string_to_u64(s: &str) -> Result { + let trimmed = s.trim(); + + if trimmed.is_empty() { + return Err(ValueError::EmptyString); + } + + // Hexadecimal with 0x/0X prefix + if trimmed.starts_with("0x") || trimmed.starts_with("0X") { + let hex_str = &trimmed[2..].trim_start_matches('0'); + if hex_str.is_empty() { + return Ok(0); // 0x000 -> + } + return u64::from_str_radix(hex_str, 16) + .map_err(|e| ValueError::InvalidFormat(format!("Hex parsing failed: {e}"))); + } + + // Binary with 0b/0B prefix + if trimmed.starts_with("0b") || trimmed.starts_with("0B") { + let bin_str = &trimmed[2..].trim_start_matches('0'); + if bin_str.is_empty() { + return Ok(0); // 0b000 -> 0 + } + if !bin_str.chars().all(|ch| ch == '0' || ch == '1') { + return Err(ValueError::InvalidFormat( + "Binary string contains invalid characters".into(), + )); + } + return u64::from_str_radix(bin_str, 2) + .map_err(|e| ValueError::InvalidFormat(format!("Binary parsing failed: {e}"))); + } + + // 8-character ASCII string conversion to u64 + if trimmed.len() <= 8 { + return ascii_string_to_u64(trimmed); + } + + // Regular decimal parsing + trimmed.parse::().map_err(|e| { + ValueError::InvalidFormat(match e.kind() { + IntErrorKind::InvalidDigit => "contains invalid digits".into(), + IntErrorKind::PosOverflow => "number too large for u64".into(), + IntErrorKind::NegOverflow => "negative numbers not allowed".into(), + IntErrorKind::Empty => "empty string".into(), + IntErrorKind::Zero => "invalid zero".into(), + _ => format!("parsing error: {e}"), + }) + }) +} + +fn ascii_string_to_u64(s: &str) -> Result { + if s.len() > 8 { + return Err(ValueError::InvalidByteStringLength(s.len())); + } + + // Ensure all characters are valid ASCII (0-127) + if !s.bytes().all(|b| b <= 127) { + return Err(ValueError::ConversionError( + "String contains non-ASCII characters".into(), + )); + } + + let mut bytes = [0; 8]; + for (idx, byte) in s.bytes().enumerate() { + bytes[idx] = byte; + } + + Ok(u64::from_be_bytes(bytes)) +} + +impl Display for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:0b}", self.0) + } +} + +impl UpperHex for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:016X}", self.0) + } +} + +impl LowerHex for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:016x}", self.0) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index e7a11a9..9082ae7 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,29 @@ -fn main() { - println!("Hello, world!"); +mod args; + +use crate::args::{Args, Operation, OutputFormat}; +use cipher_core::BlockCipher; +use clap::Parser; +use des::Des; + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let des = Des::new(args.key.as_64()); + + match args.operation { + Operation::Encrypt => { + let ciphertext = des.encrypt(&args.text.to_be_bytes())?; + println!("{ciphertext:016X}"); + } + Operation::Decrypt { output_format } => { + let plaintext = des.decrypt(&args.text.to_be_bytes())?; + match output_format.unwrap_or_default() { + OutputFormat::Binary => println!("{plaintext:064b}"), + OutputFormat::Octal => println!("{plaintext:022o}"), + OutputFormat::Decimal => println!("{plaintext}"), + OutputFormat::Hex => println!("{plaintext:016X}"), + OutputFormat::Text => println!("{plaintext}"), + } + } + } + Ok(()) } diff --git a/tui/Cargo.toml b/tui/Cargo.toml index 426e707..a3b3f24 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" [dependencies] aes = { workspace = true, optional = true } anyhow.workspace = true +cipher-core.workspace = true des = { workspace = true, optional = true } ratatui = "0.29" thiserror.workspace = true