Skip to content
Open
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
49 changes: 41 additions & 8 deletions src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how did you get - 3?
it's not in the manual, so where does this value come from?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found it by testing GNU diff 3.12, GNU accepts the one ending in …804 and rejects the one ending in …805. happy to change the exact limit if you want


#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Format {
#[default]
Expand Down Expand Up @@ -144,14 +147,8 @@ pub fn parse_params<I: Iterator<Item = OsString>>(mut opts: Peekable<I>) -> Resu
.unwrap()
.as_str();
params.tabsize = match tabsize_str.parse::<usize>() {
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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't fix the crash, it just moves it
-t --tabsize=9223372036854775804 with a tab in the input still aborts in do_expand_tabs (with_capacity + ntabs * (tabsize - 1) overflow)
could you please fix do_expand_tabs instead (no huge preallocation, write the spaces as a stream)?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, it did just move the crash. I've changed do_expand_tabs the way you suggested: it no longer reserves a big block of memory up front, and it writes the spaces in small chunks. One thing it doesn't fix yet is that diff still holds its whole output in memory, so a big enough value will still run out of memory at some point. Do you want me to stream the whole output in this PR, or leave that for a separate follow-up?

_ => return Err(format!("invalid tabsize «{tabsize_str}»")),
};

continue;
Expand Down Expand Up @@ -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() {
Expand Down
88 changes: 67 additions & 21 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
pub fn do_expand_tabs<W: Write>(
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<u8>,
pub fn do_write_line<W: Write>(
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)
}
Expand Down Expand Up @@ -199,11 +205,14 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;

fn expand(line: &[u8], tabsize: usize) -> Vec<u8> {
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]
Expand Down Expand Up @@ -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<u8>,
limit: usize,
}

impl Write for LimitedWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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 {
Expand Down
17 changes: 17 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,23 @@ mod diff {
Ok(())
}

#[test]
fn huge_tabsize() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let mut file1 = NamedTempFile::new()?;
Expand Down
Loading