|
| 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 | +} |
0 commit comments