Skip to content

Commit 453d250

Browse files
authored
Merge pull request #20 from T3pp31/feat/bounded-input-and-ci-hardening
feat: bounded CLI input reading and CI hardening
2 parents add1116 + 88f1518 commit 453d250

9 files changed

Lines changed: 390 additions & 86 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,15 @@ jobs:
3131
run: cargo test --verbose
3232

3333
- name: Run clippy
34-
run: cargo clippy -- -D warnings
34+
run: cargo clippy --all-targets --all-features -- -D warnings
3535

3636
- name: Check formatting
3737
run: cargo fmt --check
3838

39+
- name: Audit dependencies
40+
uses: rustsec/audit-check@v2.0.0
41+
with:
42+
token: ${{ secrets.GITHUB_TOKEN }}
43+
3944
- name: Build
4045
run: cargo build --release

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ fn main() {
4646
}
4747
```
4848

49+
### Basic vs Safe APIs
50+
51+
| API | Shift range | Empty / whitespace-only text |
52+
|-----|-------------|------------------------------|
53+
| `encrypt` / `decrypt` | Any `i16` (normalized mod 26) | Allowed |
54+
| `encrypt_safe` / `decrypt_safe` | -25 to 25 only | Returns `CipherError::EmptyText` |
55+
56+
Use `*_safe` when you want validation errors instead of silent normalization.
57+
4958
### Safe Functions with Error Handling
5059

5160
```rust
@@ -128,6 +137,24 @@ caesar_cipher_enc_dec decrypt --file encrypted.txt --shift 5 --output decrypted.
128137
caesar_cipher_enc_dec encrypt --text "Hello" --shift 3 --safe
129138
```
130139

140+
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.
141+
142+
### Input limits and file requirements
143+
144+
Maximum payload size is **10 MB** (`MAX_INPUT_SIZE` in `config`). How the cap is applied depends on the input path:
145+
146+
| Input path | What is limited | Notes |
147+
|------------|-----------------|-------|
148+
| `--text` | String length | Exactly 10 MB is allowed (`len > MAX` is rejected). |
149+
| `--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. |
150+
| 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. |
151+
152+
Additional notes:
153+
154+
- Shift prompts in interactive mode are capped at **64 bytes** per line (`MAX_SHIFT_LINE_SIZE`).
155+
- Stdin uses chunked reads (8 KB buffer) for performance; the 10 MB cap still applies to line content.
156+
- If you raise `MAX_INPUT_SIZE` in the future, consider tuning the read buffer in `bounded_input.rs`.
157+
131158
## Supported Characters
132159

133160
- **Uppercase letters**: A-Z

src/bounded_input.rs

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
//! Bounded input reading for CLI and interactive mode.
2+
//!
3+
//! Reads stdin lines and files with a hard byte limit so untrusted input
4+
//! cannot exhaust memory before validation runs.
5+
6+
use std::fs::File;
7+
use std::io::{self, Read};
8+
use std::path::Path;
9+
10+
/// Chunk size for bounded line reads (not the input cap; keeps I/O efficient up to `max_bytes`).
11+
const READ_CHUNK_SIZE: usize = 8192;
12+
13+
/// Returns a consistent error message when input exceeds `max_bytes`.
14+
pub fn input_size_exceeded_message(max_bytes: usize) -> String {
15+
format!("Input exceeds maximum size of {} bytes", max_bytes)
16+
}
17+
18+
/// Reads a single line from `reader`, stopping at newline or EOF.
19+
///
20+
/// At most `max_bytes` bytes of **line content** may appear before the newline.
21+
/// The trailing `\n` (if present) is stored but does not count toward `max_bytes`.
22+
/// If a non-newline byte would push content past `max_bytes`, returns an error.
23+
pub fn read_line_bounded<R: Read>(reader: &mut R, max_bytes: usize) -> io::Result<String> {
24+
let mut line = Vec::with_capacity(max_bytes.min(READ_CHUNK_SIZE));
25+
let mut buf = [0u8; READ_CHUNK_SIZE];
26+
27+
loop {
28+
let n = reader.read(&mut buf)?;
29+
if n == 0 {
30+
break;
31+
}
32+
for &byte in &buf[..n] {
33+
if byte == b'\n' {
34+
line.push(byte);
35+
return String::from_utf8(line)
36+
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e));
37+
}
38+
if line.len() >= max_bytes {
39+
return Err(io::Error::new(
40+
io::ErrorKind::InvalidData,
41+
input_size_exceeded_message(max_bytes),
42+
));
43+
}
44+
line.push(byte);
45+
}
46+
}
47+
48+
String::from_utf8(line).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
49+
}
50+
51+
/// Reads a regular file up to `max_bytes`, with streaming size enforcement.
52+
///
53+
/// Rejects non-regular files (directories, devices, FIFOs, etc.) and stops
54+
/// reading once more than `max_bytes` have been read (TOCTOU-safe).
55+
pub fn read_file_bounded(path: impl AsRef<Path>, max_bytes: usize) -> io::Result<String> {
56+
let path = path.as_ref();
57+
let path_display = path.display().to_string();
58+
59+
let metadata = std::fs::metadata(path).map_err(|e| {
60+
io::Error::new(
61+
io::ErrorKind::NotFound,
62+
format!("Failed to read file '{}': {}", path_display, e),
63+
)
64+
})?;
65+
66+
if !metadata.is_file() {
67+
return Err(io::Error::new(
68+
io::ErrorKind::InvalidInput,
69+
format!("Input path '{}' is not a regular file", path_display),
70+
));
71+
}
72+
73+
if metadata.len() > max_bytes as u64 {
74+
return Err(io::Error::new(
75+
io::ErrorKind::InvalidData,
76+
format!(
77+
"Input file '{}' exceeds maximum size of {} bytes",
78+
path_display, max_bytes
79+
),
80+
));
81+
}
82+
83+
let file = File::open(path).map_err(|e| {
84+
io::Error::new(
85+
io::ErrorKind::NotFound,
86+
format!("Failed to read file '{}': {}", path_display, e),
87+
)
88+
})?;
89+
90+
let mut limited = file.take(max_bytes as u64 + 1);
91+
let mut bytes = Vec::new();
92+
limited.read_to_end(&mut bytes)?;
93+
94+
if bytes.len() > max_bytes {
95+
return Err(io::Error::new(
96+
io::ErrorKind::InvalidData,
97+
format!(
98+
"Input file '{}' exceeds maximum size of {} bytes",
99+
path_display, max_bytes
100+
),
101+
));
102+
}
103+
104+
String::from_utf8(bytes).map_err(|e| {
105+
io::Error::new(
106+
io::ErrorKind::InvalidData,
107+
format!("Input file '{}' is not valid UTF-8: {}", path_display, e),
108+
)
109+
})
110+
}
111+
112+
#[cfg(test)]
113+
mod tests {
114+
use super::*;
115+
use std::io::Cursor;
116+
117+
#[test]
118+
fn read_line_bounded_accepts_line_within_limit() {
119+
// Given: A line shorter than the limit
120+
let mut reader = Cursor::new(b"hello\n");
121+
122+
// When: Reading with a generous limit
123+
let result = read_line_bounded(&mut reader, 10);
124+
125+
// Then: Returns content including newline
126+
assert_eq!(result.unwrap(), "hello\n");
127+
}
128+
129+
#[test]
130+
fn read_line_bounded_rejects_oversized_line() {
131+
// Given: A line longer than the limit (no early stop on allocation)
132+
let mut reader = Cursor::new(b"abcdef\n");
133+
134+
// When: Limit is 3 bytes
135+
let result = read_line_bounded(&mut reader, 3);
136+
137+
// Then: Fails with size error
138+
assert!(result.is_err());
139+
assert!(result
140+
.unwrap_err()
141+
.to_string()
142+
.contains("exceeds maximum size"));
143+
}
144+
145+
#[test]
146+
fn read_line_bounded_eof_without_newline() {
147+
// Given: Input without trailing newline
148+
let mut reader = Cursor::new(b"hi");
149+
150+
// When: Reading until EOF
151+
let result = read_line_bounded(&mut reader, 10);
152+
153+
// Then: Returns bytes read
154+
assert_eq!(result.unwrap(), "hi");
155+
}
156+
157+
#[test]
158+
fn read_line_bounded_accepts_exactly_max_bytes_at_eof() {
159+
// Given: Exactly max_bytes of content with no trailing newline
160+
let payload = vec![b'x'; 5];
161+
let mut reader = Cursor::new(payload);
162+
163+
// When: Limit equals payload length
164+
let result = read_line_bounded(&mut reader, 5);
165+
166+
// Then: Succeeds (matches --text / file cap semantics for payload size)
167+
assert_eq!(result.unwrap(), "xxxxx");
168+
}
169+
170+
#[test]
171+
fn read_line_bounded_allows_content_plus_newline_at_limit() {
172+
// Given: max_bytes of content followed by newline (newline not counted toward cap)
173+
let mut payload = vec![b'a'; 3];
174+
payload.push(b'\n');
175+
let mut reader = Cursor::new(payload);
176+
177+
// When: Limit equals content length
178+
let result = read_line_bounded(&mut reader, 3);
179+
180+
// Then: Succeeds; newline is stored but not counted toward max_bytes
181+
assert_eq!(result.unwrap(), "aaa\n");
182+
}
183+
184+
#[test]
185+
fn read_line_bounded_rejects_one_byte_over_content_limit() {
186+
// Given: Content one byte over the limit, no newline
187+
let payload = vec![b'b'; 4];
188+
let mut reader = Cursor::new(payload);
189+
190+
// When: Limit is 3
191+
let result = read_line_bounded(&mut reader, 3);
192+
193+
// Then: Fails on the fourth byte
194+
assert!(result.is_err());
195+
}
196+
}

src/caesar_cipher.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
//! Provides easy-to-use Caesar cipher encryption and decryption.
44
//! Set text and shift number to encrypt or decrypt.
55
//!
6+
//! ## Basic vs safe APIs
7+
//!
8+
//! | Function | Shift | Empty / whitespace-only text |
9+
//! |----------|-------|--------------------------------|
10+
//! | [`encrypt`] / [`decrypt`] | Any `i16` (normalized mod 26) | Allowed |
11+
//! | [`encrypt_safe`] / [`decrypt_safe`] | -25 to 25 only | Returns [`CipherError::EmptyText`] |
12+
//!
613
//! # Usage
714
//!
815
//! ```
@@ -185,10 +192,7 @@ pub fn encrypt_safe(text: &str, shift: i16) -> Result<String, CipherError> {
185192
/// ```
186193
pub fn decrypt_safe(text: &str, shift: i16) -> Result<String, CipherError> {
187194
validate_safe_inputs(text, shift)?;
188-
189-
let negated = -(shift as i32);
190-
let normalized = negated.rem_euclid(ALPHABET_SIZE as i32) as i16;
191-
Ok(shift_text(text, normalized))
195+
Ok(decrypt(text, shift))
192196
}
193197

194198
/// Validates shared inputs for safe Caesar cipher APIs.

src/cli.rs

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ use clap::{Args, Parser, Subcommand};
22
use std::fs;
33
use std::io::{self, Write};
44

5+
use crate::bounded_input::{read_file_bounded, read_line_bounded};
56
use crate::caesar_cipher::{decrypt, decrypt_safe, encrypt, encrypt_safe};
6-
use crate::config::{DEFAULT_SHIFT, MAX_BRUTE_FORCE_SHIFT, MAX_INPUT_SIZE, MAX_SHIFT, MIN_SHIFT};
7+
use crate::config::{
8+
DEFAULT_SHIFT, MAX_BRUTE_FORCE_SHIFT, MAX_INPUT_SIZE, MAX_SHIFT, MAX_SHIFT_LINE_SIZE, MIN_SHIFT,
9+
};
710

811
/// Main CLI structure for the Caesar cipher application
912
///
@@ -154,30 +157,14 @@ fn get_input_text(
154157
}
155158

156159
if let Some(f) = file {
157-
let metadata =
158-
fs::metadata(&f).map_err(|e| format!("Failed to read file '{}': {}", f, e))?;
159-
if metadata.len() > MAX_INPUT_SIZE as u64 {
160-
return Err(format!(
161-
"Input file '{}' exceeds maximum size of {} bytes",
162-
f, MAX_INPUT_SIZE
163-
)
164-
.into());
165-
}
166-
return fs::read_to_string(&f)
167-
.map_err(|e| format!("Failed to read file '{}': {}", f, e).into());
160+
let input = read_file_bounded(&f, MAX_INPUT_SIZE).map_err(|e| e.to_string())?;
161+
return Ok(trim_trailing_newline(&input).to_string());
168162
}
169163

170164
print!("Enter text: ");
171165
io::stdout().flush()?;
172-
let mut input = String::new();
173-
io::stdin().read_line(&mut input)?;
174-
if input.len() > MAX_INPUT_SIZE {
175-
return Err(format!(
176-
"Input text exceeds maximum size of {} bytes",
177-
MAX_INPUT_SIZE
178-
)
179-
.into());
180-
}
166+
let mut stdin = io::stdin().lock();
167+
let input = read_line_bounded(&mut stdin, MAX_INPUT_SIZE).map_err(|e| e.to_string())?;
181168
Ok(trim_trailing_newline(&input).to_string())
182169
}
183170

@@ -231,8 +218,8 @@ fn output_result(
231218
fn prompt_for_text(prompt: &str) -> io::Result<String> {
232219
print!("{}", prompt);
233220
io::stdout().flush()?;
234-
let mut input = String::new();
235-
io::stdin().read_line(&mut input)?;
221+
let mut stdin = io::stdin().lock();
222+
let input = read_line_bounded(&mut stdin, MAX_INPUT_SIZE)?;
236223
Ok(trim_trailing_newline(&input).to_string())
237224
}
238225

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

285272
let (shift, warning) = validate_shift_input(&shift_str);
286273
if let Some(msg) = warning {
@@ -417,6 +404,22 @@ mod tests {
417404
// Input size limit tests
418405
// -------------------------------------------------------------------------
419406

407+
#[test]
408+
fn test_read_file_bounded_rejects_directory() {
409+
// Given: A directory path
410+
let dir = tempfile::tempdir().unwrap();
411+
412+
// When: Reading as a bounded file
413+
let result = crate::bounded_input::read_file_bounded(dir.path(), MAX_INPUT_SIZE);
414+
415+
// Then: Rejects non-regular file
416+
assert!(result.is_err());
417+
assert!(result
418+
.unwrap_err()
419+
.to_string()
420+
.contains("not a regular file"));
421+
}
422+
420423
#[test]
421424
fn test_get_input_text_oversized_file_error() {
422425
// Given: A file that exceeds MAX_INPUT_SIZE

src/config.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,21 @@ pub const DEFAULT_SHIFT: i16 = 3;
2626

2727
/// Maximum input size in bytes (10 MB)
2828
pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
29+
30+
/// Maximum bytes for a single interactive shift prompt line
31+
pub const MAX_SHIFT_LINE_SIZE: usize = 64;
32+
33+
// Compile-time checks for configuration relationships (see also tests/config_tests.rs).
34+
const _: () = {
35+
assert!(UPPERCASE_BASE < LOWERCASE_BASE);
36+
assert!(MAX_SHIFT < ALPHABET_SIZE);
37+
assert!(DEFAULT_SHIFT >= 1 && DEFAULT_SHIFT <= MAX_SHIFT);
38+
assert!(ALPHABET_SIZE > 0);
39+
assert!(MAX_SHIFT > 0);
40+
assert!(UPPERCASE_BASE > 0);
41+
assert!(LOWERCASE_BASE > 0);
42+
assert!(MAX_BRUTE_FORCE_SHIFT > 0);
43+
assert!(DEFAULT_SHIFT > 0);
44+
assert!(UPPERCASE_BASE >= 0 && UPPERCASE_BASE <= 127);
45+
assert!(LOWERCASE_BASE >= 0 && LOWERCASE_BASE <= 127);
46+
};

0 commit comments

Comments
 (0)