Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,15 @@ jobs:
run: cargo test --verbose

- name: Run clippy
run: cargo clippy -- -D warnings
run: cargo clippy --all-targets --all-features -- -D warnings

- name: Check formatting
run: cargo fmt --check

- name: Audit dependencies
uses: rustsec/audit-check@v2.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}

- name: Build
run: cargo build --release
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ fn main() {
}
```

### Basic vs Safe APIs

| API | Shift range | Empty / whitespace-only text |
|-----|-------------|------------------------------|
| `encrypt` / `decrypt` | Any `i16` (normalized mod 26) | Allowed |
| `encrypt_safe` / `decrypt_safe` | -25 to 25 only | Returns `CipherError::EmptyText` |

Use `*_safe` when you want validation errors instead of silent normalization.

### Safe Functions with Error Handling

```rust
Expand Down Expand Up @@ -128,6 +137,24 @@ caesar_cipher_enc_dec decrypt --file encrypted.txt --shift 5 --output decrypted.
caesar_cipher_enc_dec encrypt --text "Hello" --shift 3 --safe
```

Without `--safe`, the CLI accepts any `i16` shift (values are normalized modulo 26) and allows empty input. With `--safe`, shift must be in -25..=25 and text must not be empty or whitespace-only.

### Input limits and file requirements

Maximum payload size is **10 MB** (`MAX_INPUT_SIZE` in `config`). How the cap is applied depends on the input path:

| Input path | What is limited | Notes |
|------------|-----------------|-------|
| `--text` | String length | Exactly 10 MB is allowed (`len > MAX` is rejected). |
| `--file` | Entire file size | Exactly 10 MB files are allowed. Must be a **regular file** (not a directory, device, or FIFO). Read uses a streaming byte cap. |
| Stdin / interactive line | Line **content** before `\n` | Up to 10 MB of content per line; `\n` is not counted toward the cap (buffer may be up to 10 MB + 1 byte). EOF without newline also allows exactly 10 MB. |

Additional notes:

- Shift prompts in interactive mode are capped at **64 bytes** per line (`MAX_SHIFT_LINE_SIZE`).
- Stdin uses chunked reads (8 KB buffer) for performance; the 10 MB cap still applies to line content.
- If you raise `MAX_INPUT_SIZE` in the future, consider tuning the read buffer in `bounded_input.rs`.

## Supported Characters

- **Uppercase letters**: A-Z
Expand Down
196 changes: 196 additions & 0 deletions src/bounded_input.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
//! Bounded input reading for CLI and interactive mode.
//!
//! Reads stdin lines and files with a hard byte limit so untrusted input
//! cannot exhaust memory before validation runs.

use std::fs::File;
use std::io::{self, Read};
use std::path::Path;

/// Chunk size for bounded line reads (not the input cap; keeps I/O efficient up to `max_bytes`).
const READ_CHUNK_SIZE: usize = 8192;

/// Returns a consistent error message when input exceeds `max_bytes`.
pub fn input_size_exceeded_message(max_bytes: usize) -> String {
format!("Input exceeds maximum size of {} bytes", max_bytes)
}

/// Reads a single line from `reader`, stopping at newline or EOF.
///
/// At most `max_bytes` bytes of **line content** may appear before the newline.
/// The trailing `\n` (if present) is stored but does not count toward `max_bytes`.
/// If a non-newline byte would push content past `max_bytes`, returns an error.
pub fn read_line_bounded<R: Read>(reader: &mut R, max_bytes: usize) -> io::Result<String> {
let mut line = Vec::with_capacity(max_bytes.min(READ_CHUNK_SIZE));
let mut buf = [0u8; READ_CHUNK_SIZE];

loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
for &byte in &buf[..n] {
if byte == b'\n' {
line.push(byte);
return String::from_utf8(line)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e));
}
if line.len() >= max_bytes {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
input_size_exceeded_message(max_bytes),
));
}
line.push(byte);
}
}

String::from_utf8(line).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

/// Reads a regular file up to `max_bytes`, with streaming size enforcement.
///
/// Rejects non-regular files (directories, devices, FIFOs, etc.) and stops
/// reading once more than `max_bytes` have been read (TOCTOU-safe).
pub fn read_file_bounded(path: impl AsRef<Path>, max_bytes: usize) -> io::Result<String> {
let path = path.as_ref();
let path_display = path.display().to_string();

let metadata = std::fs::metadata(path).map_err(|e| {
io::Error::new(
io::ErrorKind::NotFound,
format!("Failed to read file '{}': {}", path_display, e),
)
})?;

if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Input path '{}' is not a regular file", path_display),
));
}

if metadata.len() > max_bytes as u64 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Input file '{}' exceeds maximum size of {} bytes",
path_display, max_bytes
),
));
}

let file = File::open(path).map_err(|e| {
io::Error::new(
io::ErrorKind::NotFound,
format!("Failed to read file '{}': {}", path_display, e),
)
})?;

let mut limited = file.take(max_bytes as u64 + 1);
let mut bytes = Vec::new();
limited.read_to_end(&mut bytes)?;

if bytes.len() > max_bytes {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Input file '{}' exceeds maximum size of {} bytes",
path_display, max_bytes
),
));
}

String::from_utf8(bytes).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Input file '{}' is not valid UTF-8: {}", path_display, e),
)
})
}

#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;

#[test]
fn read_line_bounded_accepts_line_within_limit() {
// Given: A line shorter than the limit
let mut reader = Cursor::new(b"hello\n");

// When: Reading with a generous limit
let result = read_line_bounded(&mut reader, 10);

// Then: Returns content including newline
assert_eq!(result.unwrap(), "hello\n");
}

#[test]
fn read_line_bounded_rejects_oversized_line() {
// Given: A line longer than the limit (no early stop on allocation)
let mut reader = Cursor::new(b"abcdef\n");

// When: Limit is 3 bytes
let result = read_line_bounded(&mut reader, 3);

// Then: Fails with size error
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("exceeds maximum size"));
}

#[test]
fn read_line_bounded_eof_without_newline() {
// Given: Input without trailing newline
let mut reader = Cursor::new(b"hi");

// When: Reading until EOF
let result = read_line_bounded(&mut reader, 10);

// Then: Returns bytes read
assert_eq!(result.unwrap(), "hi");
}

#[test]
fn read_line_bounded_accepts_exactly_max_bytes_at_eof() {
// Given: Exactly max_bytes of content with no trailing newline
let payload = vec![b'x'; 5];
let mut reader = Cursor::new(payload);

// When: Limit equals payload length
let result = read_line_bounded(&mut reader, 5);

// Then: Succeeds (matches --text / file cap semantics for payload size)
assert_eq!(result.unwrap(), "xxxxx");
}

#[test]
fn read_line_bounded_allows_content_plus_newline_at_limit() {
// Given: max_bytes of content followed by newline (newline not counted toward cap)
let mut payload = vec![b'a'; 3];
payload.push(b'\n');
let mut reader = Cursor::new(payload);

// When: Limit equals content length
let result = read_line_bounded(&mut reader, 3);

// Then: Succeeds; newline is stored but not counted toward max_bytes
assert_eq!(result.unwrap(), "aaa\n");
}

#[test]
fn read_line_bounded_rejects_one_byte_over_content_limit() {
// Given: Content one byte over the limit, no newline
let payload = vec![b'b'; 4];
let mut reader = Cursor::new(payload);

// When: Limit is 3
let result = read_line_bounded(&mut reader, 3);

// Then: Fails on the fourth byte
assert!(result.is_err());
}
}
12 changes: 8 additions & 4 deletions src/caesar_cipher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
//! Provides easy-to-use Caesar cipher encryption and decryption.
//! Set text and shift number to encrypt or decrypt.
//!
//! ## Basic vs safe APIs
//!
//! | Function | Shift | Empty / whitespace-only text |
//! |----------|-------|--------------------------------|
//! | [`encrypt`] / [`decrypt`] | Any `i16` (normalized mod 26) | Allowed |
//! | [`encrypt_safe`] / [`decrypt_safe`] | -25 to 25 only | Returns [`CipherError::EmptyText`] |
//!
//! # Usage
//!
//! ```
Expand Down Expand Up @@ -185,10 +192,7 @@ pub fn encrypt_safe(text: &str, shift: i16) -> Result<String, CipherError> {
/// ```
pub fn decrypt_safe(text: &str, shift: i16) -> Result<String, CipherError> {
validate_safe_inputs(text, shift)?;

let negated = -(shift as i32);
let normalized = negated.rem_euclid(ALPHABET_SIZE as i32) as i16;
Ok(shift_text(text, normalized))
Ok(decrypt(text, shift))
}

/// Validates shared inputs for safe Caesar cipher APIs.
Expand Down
53 changes: 28 additions & 25 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ use clap::{Args, Parser, Subcommand};
use std::fs;
use std::io::{self, Write};

use crate::bounded_input::{read_file_bounded, read_line_bounded};
use crate::caesar_cipher::{decrypt, decrypt_safe, encrypt, encrypt_safe};
use crate::config::{DEFAULT_SHIFT, MAX_BRUTE_FORCE_SHIFT, MAX_INPUT_SIZE, MAX_SHIFT, MIN_SHIFT};
use crate::config::{
DEFAULT_SHIFT, MAX_BRUTE_FORCE_SHIFT, MAX_INPUT_SIZE, MAX_SHIFT, MAX_SHIFT_LINE_SIZE, MIN_SHIFT,
};

/// Main CLI structure for the Caesar cipher application
///
Expand Down Expand Up @@ -154,30 +157,14 @@ fn get_input_text(
}

if let Some(f) = file {
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());
}
return fs::read_to_string(&f)
.map_err(|e| format!("Failed to read file '{}': {}", f, e).into());
let input = read_file_bounded(&f, MAX_INPUT_SIZE).map_err(|e| e.to_string())?;
return Ok(trim_trailing_newline(&input).to_string());
}

print!("Enter 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());
}
let mut stdin = io::stdin().lock();
let input = read_line_bounded(&mut stdin, MAX_INPUT_SIZE).map_err(|e| e.to_string())?;
Ok(trim_trailing_newline(&input).to_string())
}

Expand Down Expand Up @@ -231,8 +218,8 @@ fn output_result(
fn prompt_for_text(prompt: &str) -> io::Result<String> {
print!("{}", prompt);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let mut stdin = io::stdin().lock();
let input = read_line_bounded(&mut stdin, MAX_INPUT_SIZE)?;
Ok(trim_trailing_newline(&input).to_string())
}

Expand Down Expand Up @@ -279,8 +266,8 @@ pub(crate) fn validate_shift_input(input: &str) -> (i16, Option<String>) {
fn prompt_for_shift() -> io::Result<i16> {
print!("Enter shift value (default: {}): ", DEFAULT_SHIFT);
io::stdout().flush()?;
let mut shift_str = String::new();
io::stdin().read_line(&mut shift_str)?;
let mut stdin = io::stdin().lock();
let shift_str = read_line_bounded(&mut stdin, MAX_SHIFT_LINE_SIZE)?;

let (shift, warning) = validate_shift_input(&shift_str);
if let Some(msg) = warning {
Expand Down Expand Up @@ -417,6 +404,22 @@ mod tests {
// Input size limit tests
// -------------------------------------------------------------------------

#[test]
fn test_read_file_bounded_rejects_directory() {
// Given: A directory path
let dir = tempfile::tempdir().unwrap();

// When: Reading as a bounded file
let result = crate::bounded_input::read_file_bounded(dir.path(), MAX_INPUT_SIZE);

// Then: Rejects non-regular file
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("not a regular file"));
}

#[test]
fn test_get_input_text_oversized_file_error() {
// Given: A file that exceeds MAX_INPUT_SIZE
Expand Down
18 changes: 18 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,21 @@ pub const DEFAULT_SHIFT: i16 = 3;

/// Maximum input size in bytes (10 MB)
pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;

/// Maximum bytes for a single interactive shift prompt line
pub const MAX_SHIFT_LINE_SIZE: usize = 64;

// Compile-time checks for configuration relationships (see also tests/config_tests.rs).
const _: () = {
assert!(UPPERCASE_BASE < LOWERCASE_BASE);
assert!(MAX_SHIFT < ALPHABET_SIZE);
assert!(DEFAULT_SHIFT >= 1 && DEFAULT_SHIFT <= MAX_SHIFT);
assert!(ALPHABET_SIZE > 0);
assert!(MAX_SHIFT > 0);
assert!(UPPERCASE_BASE > 0);
assert!(LOWERCASE_BASE > 0);
assert!(MAX_BRUTE_FORCE_SHIFT > 0);
assert!(DEFAULT_SHIFT > 0);
assert!(UPPERCASE_BASE >= 0 && UPPERCASE_BASE <= 127);
assert!(LOWERCASE_BASE >= 0 && LOWERCASE_BASE <= 127);
};
Loading
Loading