diff --git a/src/params.rs b/src/params.rs index 74ef3e3..42bb48a 100644 --- a/src/params.rs +++ b/src/params.rs @@ -4,6 +4,9 @@ use std::path::PathBuf; use regex::Regex; +/// Largest value accepted for `--tabsize`, matching GNU diff. +const MAX_TABSIZE: usize = isize::MAX as usize - 3; + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum Format { #[default] @@ -144,14 +147,8 @@ pub fn parse_params>(mut opts: Peekable) -> Resu .unwrap() .as_str(); params.tabsize = match tabsize_str.parse::() { - Ok(num) => { - if num == 0 { - return Err("invalid tabsize «0»".to_string()); - } - - num - } - Err(_) => return Err(format!("invalid tabsize «{tabsize_str}»")), + Ok(num) if num != 0 && num <= MAX_TABSIZE => num, + _ => return Err(format!("invalid tabsize «{tabsize_str}»")), }; continue; @@ -811,6 +808,42 @@ mod tests { .peekable() ) .is_err()); + for too_large in [(MAX_TABSIZE + 1).to_string(), usize::MAX.to_string()] { + assert_eq!( + Err(format!("invalid tabsize «{too_large}»")), + parse_params( + [ + os("diff"), + os(&format!("--tabsize={too_large}")), + os("foo"), + os("bar") + ] + .iter() + .cloned() + .peekable() + ) + ); + } + assert_eq!( + Ok(Params { + executable: os("diff"), + from: os("foo"), + to: os("bar"), + tabsize: MAX_TABSIZE, + ..Default::default() + }), + parse_params( + [ + os("diff"), + os(&format!("--tabsize={MAX_TABSIZE}")), + os("foo"), + os("bar") + ] + .iter() + .cloned() + .peekable() + ) + ); } #[test] fn double_dash() { diff --git a/src/utils.rs b/src/utils.rs index d9415af..005b8e2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -7,46 +7,52 @@ use regex::Regex; use std::{ffi::OsString, io::Write}; use unicode_width::UnicodeWidthStr; -/// Replace tabs by spaces in the input line. +/// Write the input line to an output stream, replacing tabs by spaces. /// Correctly handle multi-bytes characters. +/// The spaces are written in small chunks, so a huge tab size does not +/// need a huge buffer. /// This assumes that line does not contain any line breaks (if it does, the result is undefined). -#[must_use] -pub fn do_expand_tabs(line: &[u8], tabsize: usize) -> Vec { +pub fn do_expand_tabs( + output: &mut W, + line: &[u8], + tabsize: usize, +) -> std::io::Result<()> { + const SPACES: [u8; 64] = [b' '; 64]; let tab = b'\t'; - let ntabs = line.iter().filter(|c| **c == tab).count(); - if ntabs == 0 { - return line.to_vec(); - } - let mut result = Vec::with_capacity(line.len() + ntabs * (tabsize - 1)); let mut offset = 0; let mut iter = line.split(|c| *c == tab).peekable(); while let Some(chunk) = iter.next() { - match String::from_utf8(chunk.to_vec()) { - Ok(s) => offset += UnicodeWidthStr::width(s.as_str()), + match std::str::from_utf8(chunk) { + Ok(s) => offset += UnicodeWidthStr::width(s), Err(_) => offset += chunk.len(), } - result.extend_from_slice(chunk); + output.write_all(chunk)?; if iter.peek().is_some() { - result.resize(result.len() + tabsize - offset % tabsize, b' '); + let mut spaces = tabsize - offset % tabsize; + while spaces > 0 { + let n = spaces.min(SPACES.len()); + output.write_all(&SPACES[..n])?; + spaces -= n; + } offset = 0; } } - result + Ok(()) } /// Write a single line to an output stream, expanding tabs to space if necessary. /// This assumes that line does not contain any line breaks /// (if it does and tabs are to be expanded to spaces, the result is undefined). -pub fn do_write_line( - output: &mut Vec, +pub fn do_write_line( + output: &mut W, line: &[u8], expand_tabs: bool, tabsize: usize, ) -> std::io::Result<()> { if expand_tabs { - output.write_all(do_expand_tabs(line, tabsize).as_slice()) + do_expand_tabs(output, line, tabsize) } else { output.write_all(line) } @@ -199,11 +205,14 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + fn expand(line: &[u8], tabsize: usize) -> Vec { + let mut output = Vec::new(); + assert!(do_expand_tabs(&mut output, line, tabsize).is_ok()); + output + } + fn assert_tab_expansion(line: &str, tabsize: usize, expected: &str) { - assert_eq!( - do_expand_tabs(line.as_bytes(), tabsize), - expected.as_bytes() - ); + assert_eq!(expand(line.as_bytes(), tabsize), expected.as_bytes()); } #[test] @@ -232,10 +241,47 @@ mod tests { fn invalid_utf8() { // [240, 240, 152, 137] is an invalid UTF-8 sequence, so it is handled as 4 bytes assert_eq!( - do_expand_tabs(&[240, 240, 152, 137, 9, 102, 111, 111], 8), + expand(&[240, 240, 152, 137, 9, 102, 111, 111], 8), &[240, 240, 152, 137, 32, 32, 32, 32, 102, 111, 111] ); } + + /// A writer that fails once it has been given `limit` bytes. + struct LimitedWriter { + written: Vec, + limit: usize, + } + + impl Write for LimitedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let n = buf.len().min(self.limit - self.written.len()); + if n == 0 { + return Err(std::io::ErrorKind::WriteZero.into()); + } + self.written.extend_from_slice(&buf[..n]); + Ok(n) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[test] + fn huge_tabsize() { + // The spaces are streamed to the writer, so a huge tab size + // neither overflows nor needs a huge buffer up front. + for tabsize in [usize::MAX / 2, usize::MAX] { + let mut output = LimitedWriter { + written: Vec::new(), + limit: 1000, + }; + assert!(do_expand_tabs(&mut output, b"a\tb\tc", tabsize).is_err()); + let mut expected = b"a".to_vec(); + expected.resize(1000, b' '); + assert_eq!(output.written, expected); + } + } } mod write_line { diff --git a/tests/integration.rs b/tests/integration.rs index 12aabb8..8add8e0 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -224,6 +224,23 @@ mod diff { Ok(()) } + #[test] + fn huge_tabsize() -> Result<(), Box> { + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("--tabsize=18446744073709551615") + .arg("foo") + .arg("bar"); + cmd.assert() + .code(predicate::eq(2)) + .failure() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains( + "invalid tabsize «18446744073709551615»", + )); + Ok(()) + } + #[test] fn read_from_stdin() -> Result<(), Box> { let mut file1 = NamedTempFile::new()?;