diff --git a/Cargo.lock b/Cargo.lock index 6cb3fc9..e054c56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" [[package]] name = "caesar_cipher_enc_dec" -version = "1.0.3" +version = "1.0.4" dependencies = [ "clap", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 043c913..90346ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "caesar_cipher_enc_dec" -version = "1.0.3" +version = "1.0.4" edition = "2021" description = "can easily use caesar cipher" license = "MIT" diff --git a/src/caesar_cipher.rs b/src/caesar_cipher.rs index a9da488..85b2bb7 100644 --- a/src/caesar_cipher.rs +++ b/src/caesar_cipher.rs @@ -52,7 +52,7 @@ impl std::fmt::Display for CipherError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { CipherError::InvalidShift(msg) => write!(f, "Invalid shift value: {}", msg), - CipherError::EmptyText => write!(f, "Input text cannot be empty"), + CipherError::EmptyText => write!(f, "Input text cannot be empty or whitespace-only"), } } } @@ -143,7 +143,7 @@ pub fn decrypt(text: &str, shift: i16) -> String { /// assert!(encrypt_safe("Hello", 26).is_err()); /// ``` pub fn encrypt_safe(text: &str, shift: i16) -> Result { - if text.is_empty() { + if text.trim().is_empty() { return Err(CipherError::EmptyText); } @@ -185,7 +185,7 @@ pub fn encrypt_safe(text: &str, shift: i16) -> Result { /// assert_eq!(result, "Hello"); /// ``` pub fn decrypt_safe(text: &str, shift: i16) -> Result { - if text.is_empty() { + if text.trim().is_empty() { return Err(CipherError::EmptyText); } diff --git a/src/cli.rs b/src/cli.rs index af53cb3..711bd90 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,9 +1,9 @@ -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; use std::fs; use std::io::{self, Write}; use crate::caesar_cipher::{decrypt, decrypt_safe, encrypt, encrypt_safe}; -use crate::config::{DEFAULT_SHIFT, MAX_BRUTE_FORCE_SHIFT}; +use crate::config::{DEFAULT_SHIFT, MAX_BRUTE_FORCE_SHIFT, MAX_INPUT_SIZE, MAX_SHIFT, MIN_SHIFT}; /// Main CLI structure for the Caesar cipher application /// @@ -18,6 +18,30 @@ pub struct Cli { pub command: Commands, } +/// Common arguments shared between encrypt and decrypt commands +#[derive(Args)] +pub struct CipherArgs { + /// Text to process + #[arg(short, long)] + pub text: Option, + + /// Input file path + #[arg(short = 'f', long)] + pub file: Option, + + /// Shift value (any integer; safe mode: -25 to 25, default: 3) + #[arg(short, long, default_value = "3")] + pub shift: i16, + + /// Output file path + #[arg(short, long)] + pub output: Option, + + /// Use safe mode with error checking + #[arg(long)] + pub safe: bool, +} + /// Available commands for the Caesar cipher CLI /// /// Each variant represents a different operation that can be performed @@ -25,49 +49,9 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { /// Encrypt text using Caesar cipher - Encrypt { - /// Text to encrypt - #[arg(short, long)] - text: Option, - - /// Input file path - #[arg(short = 'f', long)] - file: Option, - - /// Shift value (1-25) - #[arg(short, long, default_value = "3")] - shift: i16, - - /// Output file path - #[arg(short, long)] - output: Option, - - /// Use safe mode with error checking - #[arg(long)] - safe: bool, - }, + Encrypt(CipherArgs), /// Decrypt text using Caesar cipher - Decrypt { - /// Text to decrypt - #[arg(short, long)] - text: Option, - - /// Input file path - #[arg(short = 'f', long)] - file: Option, - - /// Shift value (1-25) - #[arg(short, long, default_value = "3")] - shift: i16, - - /// Output file path - #[arg(short, long)] - output: Option, - - /// Use safe mode with error checking - #[arg(long)] - safe: bool, - }, + Decrypt(CipherArgs), /// Interactive mode Interactive, /// Show all possible decryptions (brute force) @@ -102,36 +86,24 @@ pub fn run_cli() -> Result<(), Box> { let cli = Cli::parse(); match cli.command { - Commands::Encrypt { - text, - file, - shift, - output, - safe, - } => { - let input_text = get_input_text(text, file)?; - let result = if safe { - encrypt_safe(&input_text, shift)? + Commands::Encrypt(args) => { + let input_text = get_input_text(args.text, args.file)?; + let result = if args.safe { + encrypt_safe(&input_text, args.shift)? } else { - encrypt(&input_text, shift) + encrypt(&input_text, args.shift) }; - output_result(&result, output)?; + output_result(&result, args.output)?; } - Commands::Decrypt { - text, - file, - shift, - output, - safe, - } => { - let input_text = get_input_text(text, file)?; - let result = if safe { - decrypt_safe(&input_text, shift)? + Commands::Decrypt(args) => { + let input_text = get_input_text(args.text, args.file)?; + let result = if args.safe { + decrypt_safe(&input_text, args.shift)? } else { - decrypt(&input_text, shift) + decrypt(&input_text, args.shift) }; - output_result(&result, output)?; + output_result(&result, args.output)?; } Commands::Interactive => { @@ -172,8 +144,26 @@ fn get_input_text( file: Option, ) -> Result> { match (text, file) { - (Some(t), None) => Ok(t), + (Some(t), None) => { + if t.len() > MAX_INPUT_SIZE { + return Err(format!( + "Input text exceeds maximum size of {} bytes", + MAX_INPUT_SIZE + ) + .into()); + } + Ok(t) + } (None, Some(f)) => { + let metadata = + fs::metadata(&f).map_err(|e| format!("Failed to read file '{}': {}", f, e))?; + if metadata.len() > MAX_INPUT_SIZE as u64 { + return Err(format!( + "Input file '{}' exceeds maximum size of {} bytes", + f, MAX_INPUT_SIZE + ) + .into()); + } fs::read_to_string(&f).map_err(|e| format!("Failed to read file '{}': {}", f, e).into()) } (Some(_), Some(_)) => Err("Cannot specify both text and file".into()), @@ -182,6 +172,13 @@ fn get_input_text( io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; + if input.len() > MAX_INPUT_SIZE { + return Err(format!( + "Input text exceeds maximum size of {} bytes", + MAX_INPUT_SIZE + ) + .into()); + } Ok(input.trim().to_string()) } } @@ -237,24 +234,57 @@ fn prompt_for_text(prompt: &str) -> io::Result { Ok(input.trim().to_string()) } +/// Validates shift input string and returns parsed value with optional warning +/// +/// # Arguments +/// +/// * `input` - Raw input string from user +/// +/// # Returns +/// +/// A tuple of (shift value, optional warning message). +/// Returns default shift with warning if input is invalid. +pub(crate) fn validate_shift_input(input: &str) -> (i16, Option) { + let trimmed = input.trim(); + if trimmed.is_empty() { + return (DEFAULT_SHIFT, None); + } + + match trimmed.parse::() { + Ok(shift) => { + if !(MIN_SHIFT..=MAX_SHIFT).contains(&shift) { + let warning = format!( + "Warning: shift {} is outside the typical range ({} to {}). Value will be normalized.", + shift, MIN_SHIFT, MAX_SHIFT + ); + (shift, Some(warning)) + } else { + (shift, None) + } + } + Err(_) => { + let warning = format!("Invalid shift value, using default ({})", DEFAULT_SHIFT); + (DEFAULT_SHIFT, Some(warning)) + } + } +} + /// Prompts the user for a shift value with validation /// /// # Returns /// /// A valid shift value, or the default if input is invalid fn prompt_for_shift() -> io::Result { - print!("Enter shift value (1-{}): ", MAX_BRUTE_FORCE_SHIFT); + print!("Enter shift value (default: {}): ", DEFAULT_SHIFT); io::stdout().flush()?; let mut shift_str = String::new(); io::stdin().read_line(&mut shift_str)?; - match shift_str.trim().parse::() { - Ok(shift) => Ok(shift), - Err(_) => { - println!("Invalid shift value, using default ({})", DEFAULT_SHIFT); - Ok(DEFAULT_SHIFT) - } + let (shift, warning) = validate_shift_input(&shift_str); + if let Some(msg) = warning { + println!("{}", msg); } + Ok(shift) } /// Runs the interactive mode for the Caesar cipher @@ -316,8 +346,8 @@ fn run_interactive_mode() -> Result<(), Box> { /// Performs brute force decryption on the given text /// /// This function attempts to decrypt the input text using all possible -/// shift values (1-25) and displays the results. This is useful when -/// the shift value is unknown. +/// shift values (0-25) and displays the results. This is useful when +/// the shift value is unknown. Shift 0 shows the original text for reference. /// /// # Arguments /// @@ -327,7 +357,7 @@ fn run_brute_force(text: &str) { println!("Original: {}", text); println!("Trying all possible shifts:"); - for shift in 1..=MAX_BRUTE_FORCE_SHIFT { + for shift in 0..=MAX_BRUTE_FORCE_SHIFT { let decrypted = decrypt(text, shift); println!("Shift {:2}: {}", shift, decrypted); } @@ -376,4 +406,164 @@ mod tests { let content = fs::read_to_string(file_path).unwrap(); assert_eq!(content, "Test output"); } + + // ------------------------------------------------------------------------- + // Input size limit tests + // ------------------------------------------------------------------------- + + #[test] + fn test_get_input_text_oversized_file_error() { + // Given: A file that exceeds MAX_INPUT_SIZE + use std::io::Write as _; + let mut temp_file = NamedTempFile::new().unwrap(); + // Write MAX_INPUT_SIZE + 1 bytes to exceed the limit + let oversized_data = vec![b'A'; MAX_INPUT_SIZE + 1]; + temp_file.write_all(&oversized_data).unwrap(); + temp_file.flush().unwrap(); + + // When: Reading from oversized file + let result = get_input_text(None, Some(temp_file.path().to_string_lossy().to_string())); + + // Then: Returns error about exceeding maximum size + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("exceeds maximum size")); + } + + #[test] + fn test_get_input_text_file_at_max_size_succeeds() { + // Given: A file exactly at MAX_INPUT_SIZE (should succeed) + use std::io::Write as _; + let mut temp_file = NamedTempFile::new().unwrap(); + let data = vec![b'A'; MAX_INPUT_SIZE]; + temp_file.write_all(&data).unwrap(); + temp_file.flush().unwrap(); + + // When: Reading from file at the limit + let result = get_input_text(None, Some(temp_file.path().to_string_lossy().to_string())); + + // Then: Returns Ok + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), MAX_INPUT_SIZE); + } + + // ------------------------------------------------------------------------- + // validate_shift_input tests + // ------------------------------------------------------------------------- + + #[test] + fn test_validate_shift_input_valid_value() { + // Given: A valid shift value within range + // When: Validating input "3" + let (shift, warning) = validate_shift_input("3"); + // Then: Returns 3 with no warning + assert_eq!(shift, 3); + assert!(warning.is_none()); + } + + #[test] + fn test_validate_shift_input_zero() { + // Given: Shift value of 0 (boundary) + // When: Validating input "0" + let (shift, warning) = validate_shift_input("0"); + // Then: Returns 0 with no warning + assert_eq!(shift, 0); + assert!(warning.is_none()); + } + + #[test] + fn test_validate_shift_input_max_boundary() { + // Given: Maximum valid shift value (25) + // When: Validating input "25" + let (shift, warning) = validate_shift_input("25"); + // Then: Returns 25 with no warning + assert_eq!(shift, 25); + assert!(warning.is_none()); + } + + #[test] + fn test_validate_shift_input_min_boundary() { + // Given: Minimum valid shift value (-25) + // When: Validating input "-25" + let (shift, warning) = validate_shift_input("-25"); + // Then: Returns -25 with no warning + assert_eq!(shift, -25); + assert!(warning.is_none()); + } + + #[test] + fn test_validate_shift_input_out_of_range_positive() { + // Given: Shift value above range (26) + // When: Validating input "26" + let (shift, warning) = validate_shift_input("26"); + // Then: Returns 26 with a warning + assert_eq!(shift, 26); + assert!(warning.is_some()); + assert!(warning.unwrap().contains("Warning")); + } + + #[test] + fn test_validate_shift_input_out_of_range_negative() { + // Given: Shift value below range (-26) + // When: Validating input "-26" + let (shift, warning) = validate_shift_input("-26"); + // Then: Returns -26 with a warning + assert_eq!(shift, -26); + assert!(warning.is_some()); + assert!(warning.unwrap().contains("Warning")); + } + + #[test] + fn test_validate_shift_input_far_out_of_range() { + // Given: Shift value far out of range (9999) + // When: Validating input "9999" + let (shift, warning) = validate_shift_input("9999"); + // Then: Returns 9999 with a warning containing the value + assert_eq!(shift, 9999); + assert!(warning.is_some()); + assert!(warning.unwrap().contains("9999")); + } + + #[test] + fn test_validate_shift_input_invalid_string() { + // Given: Non-numeric input + // When: Validating input "abc" + let (shift, warning) = validate_shift_input("abc"); + // Then: Returns default shift with a warning + assert_eq!(shift, DEFAULT_SHIFT); + assert!(warning.is_some()); + assert!(warning.unwrap().contains("Invalid")); + } + + #[test] + fn test_validate_shift_input_empty_string() { + // Given: Empty input + // When: Validating input "" + let (shift, warning) = validate_shift_input(""); + // Then: Returns default shift with no warning + assert_eq!(shift, DEFAULT_SHIFT); + assert!(warning.is_none()); + } + + #[test] + fn test_validate_shift_input_whitespace_only() { + // Given: Whitespace-only input + // When: Validating input " " + let (shift, warning) = validate_shift_input(" "); + // Then: Returns default shift with no warning (treated as empty) + assert_eq!(shift, DEFAULT_SHIFT); + assert!(warning.is_none()); + } + + #[test] + fn test_validate_shift_input_with_surrounding_whitespace() { + // Given: Valid value with surrounding whitespace + // When: Validating input " 5 " + let (shift, warning) = validate_shift_input(" 5 "); + // Then: Returns 5 with no warning + assert_eq!(shift, 5); + assert!(warning.is_none()); + } } diff --git a/src/config.rs b/src/config.rs index eeb80a7..90f7849 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,9 @@ pub const ALPHABET_SIZE: i16 = 26; /// Maximum valid shift value for safe functions pub const MAX_SHIFT: i16 = 25; +/// Minimum valid shift value for safe functions +pub const MIN_SHIFT: i16 = -25; + /// ASCII value of uppercase 'A' pub const UPPERCASE_BASE: i16 = 'A' as i16; @@ -20,3 +23,6 @@ pub const MAX_BRUTE_FORCE_SHIFT: i16 = 25; /// Default shift value when user input is invalid pub const DEFAULT_SHIFT: i16 = 3; + +/// Maximum input size in bytes (10 MB) +pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024; diff --git a/src/main.rs b/src/main.rs index 7f2f905..2a9bcd8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,9 +12,7 @@ use std::env; /// This function determines whether to run the CLI interface (when arguments are provided) /// or the demonstration mode (when no arguments are provided). fn main() { - let args: Vec = env::args().collect(); - - if args.len() > 1 { + if env::args().count() > 1 { if let Err(e) = cli::run_cli() { eprintln!("Error: {}", e); std::process::exit(1); diff --git a/tests/cli_output_tests.rs b/tests/cli_output_tests.rs index e737683..92a03b0 100644 --- a/tests/cli_output_tests.rs +++ b/tests/cli_output_tests.rs @@ -326,3 +326,70 @@ fn test_cli_safe_mode_empty_text_from_file() { stderr ); } + +// ============================================================================= +// Brute force shift=0 tests +// ============================================================================= + +#[test] +fn test_cli_brute_force_includes_shift_zero() { + // Given: An encrypted text + let output = std::process::Command::new("cargo") + .args(["run", "--", "brute-force", "--text", "Khoor"]) + .output() + .expect("Failed to execute CLI"); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Then: Output contains shift 0 + assert!( + stdout.contains("Shift 0:"), + "Brute force output should include shift 0, got: {}", + stdout + ); +} + +#[test] +fn test_cli_brute_force_shift_zero_is_original() { + // Given: A known encrypted text "Khoor" + let output = std::process::Command::new("cargo") + .args(["run", "--", "brute-force", "--text", "Khoor"]) + .output() + .expect("Failed to execute CLI"); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Then: Shift 0 shows the original text unchanged + assert!( + stdout.contains("Shift 0: Khoor"), + "Shift 0 should show original text 'Khoor', got: {}", + stdout + ); +} + +// ============================================================================= +// Help text tests +// ============================================================================= + +#[test] +fn test_cli_encrypt_help_shows_correct_shift_description() { + // Given: CLI with encrypt --help + let output = std::process::Command::new("cargo") + .args(["run", "--", "encrypt", "--help"]) + .output() + .expect("Failed to execute CLI"); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Then: Help text should describe shift range accurately + assert!( + stdout.contains("safe mode: -25 to 25"), + "Help text should mention safe mode range, got: {}", + stdout + ); + assert!( + !stdout.contains("Shift value (1-25)"), + "Help text should NOT contain outdated '1-25' range, got: {}", + stdout + ); +} diff --git a/tests/error_tests.rs b/tests/error_tests.rs index 32a34a4..6b4b768 100644 --- a/tests/error_tests.rs +++ b/tests/error_tests.rs @@ -13,7 +13,7 @@ fn test_cipher_error_display_empty_text() { let message = error.to_string(); // Then: Correct message - assert_eq!(message, "Input text cannot be empty"); + assert_eq!(message, "Input text cannot be empty or whitespace-only"); } #[test] diff --git a/tests/safe_api_tests.rs b/tests/safe_api_tests.rs index edda850..ef15c0c 100644 --- a/tests/safe_api_tests.rs +++ b/tests/safe_api_tests.rs @@ -195,7 +195,10 @@ mod encrypt_safe_tests { // Then: Error message is correct assert!(result.is_err()); let error = result.unwrap_err(); - assert_eq!(error.to_string(), "Input text cannot be empty"); + assert_eq!( + error.to_string(), + "Input text cannot be empty or whitespace-only" + ); } } @@ -287,4 +290,146 @@ mod decrypt_safe_tests { assert!(result.is_err()); assert!(matches!(result.unwrap_err(), CipherError::EmptyText)); } + + #[test] + fn test_decrypt_safe_whitespace_only_spaces() { + // Given: Whitespace-only text (spaces) + let text = " "; + let shift = 3; + + // When: Decrypting safely + let result = decrypt_safe(text, shift); + + // Then: Returns Err(EmptyText) + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), CipherError::EmptyText)); + } + + #[test] + fn test_decrypt_safe_whitespace_only_tabs() { + // Given: Whitespace-only text (tabs) + let text = "\t\t"; + let shift = 3; + + // When: Decrypting safely + let result = decrypt_safe(text, shift); + + // Then: Returns Err(EmptyText) + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), CipherError::EmptyText)); + } + + #[test] + fn test_decrypt_safe_whitespace_only_mixed() { + // Given: Whitespace-only text (mixed whitespace) + let text = " \t\n "; + let shift = 3; + + // When: Decrypting safely + let result = decrypt_safe(text, shift); + + // Then: Returns Err(EmptyText) + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), CipherError::EmptyText)); + } +} + +// ============================================================================= +// Whitespace validation tests (空白文字バリデーションテスト) +// ============================================================================= + +mod whitespace_validation_tests { + use super::*; + + // ------------------------------------------------------------------------- + // encrypt_safe whitespace tests (異常系) + // ------------------------------------------------------------------------- + + #[test] + fn test_encrypt_safe_whitespace_only_spaces() { + // Given: Text with only spaces + let text = " "; + let shift = 3; + + // When: Encrypting safely + let result = encrypt_safe(text, shift); + + // Then: Returns Err(EmptyText) + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), CipherError::EmptyText)); + } + + #[test] + fn test_encrypt_safe_whitespace_only_tabs() { + // Given: Text with only tabs + let text = "\t\t"; + let shift = 3; + + // When: Encrypting safely + let result = encrypt_safe(text, shift); + + // Then: Returns Err(EmptyText) + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), CipherError::EmptyText)); + } + + #[test] + fn test_encrypt_safe_whitespace_only_newlines() { + // Given: Text with only newlines + let text = "\n\n"; + let shift = 3; + + // When: Encrypting safely + let result = encrypt_safe(text, shift); + + // Then: Returns Err(EmptyText) + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), CipherError::EmptyText)); + } + + #[test] + fn test_encrypt_safe_whitespace_only_mixed() { + // Given: Text with mixed whitespace characters + let text = " \t\n "; + let shift = 3; + + // When: Encrypting safely + let result = encrypt_safe(text, shift); + + // Then: Returns Err(EmptyText) + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), CipherError::EmptyText)); + } + + // ------------------------------------------------------------------------- + // Leading/trailing whitespace with content (正常系) + // ------------------------------------------------------------------------- + + #[test] + fn test_encrypt_safe_text_with_leading_trailing_spaces() { + // Given: Text with leading and trailing spaces but valid content + let text = " Hello "; + let shift = 3; + + // When: Encrypting safely + let result = encrypt_safe(text, shift); + + // Then: Returns Ok with encrypted text (spaces preserved) + assert!(result.is_ok()); + assert_eq!(result.unwrap(), " Khoor "); + } + + #[test] + fn test_decrypt_safe_text_with_leading_trailing_spaces() { + // Given: Encrypted text with leading and trailing spaces + let text = " Khoor "; + let shift = 3; + + // When: Decrypting safely + let result = decrypt_safe(text, shift); + + // Then: Returns Ok with decrypted text (spaces preserved) + assert!(result.is_ok()); + assert_eq!(result.unwrap(), " Hello "); + } }