From 15d6c66216acd5ebccf82091923dd0e5ffd2bea2 Mon Sep 17 00:00:00 2001 From: sap1110 <238814652+sap1110@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:36:10 +0530 Subject: [PATCH 1/3] diff: reject a --tabsize too large for GNU diff A huge --tabsize was accepted and then used to size the tab expansion buffer, so the first tab in the input made diff overflow or abort on allocation failure. Reject values above isize::MAX - 3 with "invalid tabsize" and exit status 2, as GNU diff does. --- src/params.rs | 49 ++++++++++++++++++++++++++++++++++++-------- tests/integration.rs | 27 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/params.rs b/src/params.rs index 74ef3e37..42bb48ae 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/tests/integration.rs b/tests/integration.rs index 12aabb84..087564ce 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -224,6 +224,33 @@ mod diff { Ok(()) } + #[test] + fn huge_tabsize() -> Result<(), Box> { + let mut file1 = NamedTempFile::new()?; + file1.write_all("\tx\n".as_bytes())?; + let mut file2 = NamedTempFile::new()?; + file2.write_all("y\n".as_bytes())?; + for option in ["", "-u", "-c"] { + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff"); + if !option.is_empty() { + cmd.arg(option); + } + cmd.arg("-t") + .arg("--tabsize=18446744073709551615") + .arg(file1.path()) + .arg(file2.path()); + 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()?; From 9a1488a6150972e01f3f7da5aef693d775f5f33f Mon Sep 17 00:00:00 2001 From: sap1110 <238814652+sap1110@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:05:59 +0530 Subject: [PATCH 2/3] fixup! diff: reject a --tabsize too large for GNU diff --- src/utils.rs | 88 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 21 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index d9415afd..005b8e2e 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 { From 4b72b6e713a8a97379a4c075aa6ec49e2faf4c50 Mon Sep 17 00:00:00 2001 From: sap1110 <238814652+sap1110@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:05:59 +0530 Subject: [PATCH 3/3] fixup! diff: reject a --tabsize too large for GNU diff --- tests/integration.rs | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/tests/integration.rs b/tests/integration.rs index 087564ce..8add8e05 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -226,28 +226,18 @@ mod diff { #[test] fn huge_tabsize() -> Result<(), Box> { - let mut file1 = NamedTempFile::new()?; - file1.write_all("\tx\n".as_bytes())?; - let mut file2 = NamedTempFile::new()?; - file2.write_all("y\n".as_bytes())?; - for option in ["", "-u", "-c"] { - let mut cmd = cargo_bin_cmd!("diffutils"); - cmd.arg("diff"); - if !option.is_empty() { - cmd.arg(option); - } - cmd.arg("-t") - .arg("--tabsize=18446744073709551615") - .arg(file1.path()) - .arg(file2.path()); - cmd.assert() - .code(predicate::eq(2)) - .failure() - .stdout(predicate::str::is_empty()) - .stderr(predicate::str::contains( - "invalid tabsize «18446744073709551615»", - )); - } + 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(()) }