diff --git a/src/diff.rs b/src/diff.rs index 7de5f55..9f931d8 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -87,51 +87,56 @@ pub fn compare_files(params: &Params, header: Option<&[u8]>) -> Outcome { return Outcome::Identical; } - // read files - fn read_file_contents(filepath: &OsString) -> io::Result> { + // read files, along with how many leading bytes decide if they are binary + fn read_file_contents(filepath: &OsString) -> io::Result<(Vec, usize)> { + let mut content = Vec::new(); if filepath == "-" { - let mut content = Vec::new(); - io::stdin().read_to_end(&mut content).and(Ok(content)) + io::stdin().read_to_end(&mut content)?; + Ok((content, stdin_binary_probe_len())) } else { - fs::read(filepath) + let mut file = fs::File::open(filepath)?; + file.read_to_end(&mut content)?; + Ok((content, binary_probe_len(&file))) } } let mut io_error = false; let mut from_missing = false; let mut to_missing = false; - let from_content = match read_file_contents(¶ms.from) { + let (from_content, from_probe_len) = match read_file_contents(¶ms.from) { Ok(from_content) => from_content, Err(e) if e.kind() == io::ErrorKind::NotFound && params.missing_file != MissingFile::Error => { from_missing = true; - vec![] + (vec![], 0) } Err(e) => { report_failure_to_read_input_file(¶ms.executable, ¶ms.from, &e); io_error = true; - vec![] + (vec![], 0) } }; - let to_content = match read_file_contents(¶ms.to) { + let (to_content, to_probe_len) = match read_file_contents(¶ms.to) { Ok(to_content) => to_content, Err(e) if e.kind() == io::ErrorKind::NotFound && params.missing_file == MissingFile::Empty => { to_missing = true; - vec![] + (vec![], 0) } Err(e) => { report_failure_to_read_input_file(¶ms.executable, ¶ms.to, &e); io_error = true; - vec![] + (vec![], 0) } }; if io_error { return Outcome::Trouble; } - if !params.text && (is_binary(&from_content) || is_binary(&to_content)) { + if !params.text + && (is_binary(&from_content, from_probe_len) || is_binary(&to_content, to_probe_len)) + { if from_content == to_content { maybe_report_identical_files(); return Outcome::Identical; @@ -248,12 +253,48 @@ fn output_palette(params: &Params) -> Option { } } -/// Number of leading bytes inspected when deciding whether an input is binary. -const BINARY_PROBE_LEN: usize = 16 * 1024; +/// Number of leading bytes inspected when deciding whether an input is binary, +/// where the file system does not tell its preferred I/O size. +const DEFAULT_BINARY_PROBE_LEN: usize = 16 * 1024; + +/// An input is treated as binary when a NUL byte occurs in its first +/// `probe_len` bytes. +fn is_binary(content: &[u8], probe_len: usize) -> bool { + content[..content.len().min(probe_len)].contains(&0) +} + +/// Like GNU diff, only the first buffer read from a file decides whether it +/// is binary, and that buffer is as large as the preferred I/O size of the +/// file (`st_blksize`). +#[cfg(unix)] +fn binary_probe_len(file: &fs::File) -> usize { + use std::os::unix::fs::MetadataExt; + file.metadata() + .ok() + .and_then(|metadata| usize::try_from(metadata.blksize()).ok()) + .filter(|&len| len > 0) + .unwrap_or(DEFAULT_BINARY_PROBE_LEN) +} + +#[cfg(not(unix))] +fn binary_probe_len(_file: &fs::File) -> usize { + DEFAULT_BINARY_PROBE_LEN +} + +#[cfg(unix)] +fn stdin_binary_probe_len() -> usize { + use std::os::fd::AsFd; + io::stdin() + .as_fd() + .try_clone_to_owned() + .map_or(DEFAULT_BINARY_PROBE_LEN, |fd| { + binary_probe_len(&fs::File::from(fd)) + }) +} -/// An input is treated as binary when a NUL byte occurs near its start. -fn is_binary(content: &[u8]) -> bool { - content[..content.len().min(BINARY_PROBE_LEN)].contains(&0) +#[cfg(not(unix))] +fn stdin_binary_probe_len() -> usize { + DEFAULT_BINARY_PROBE_LEN } /// Print the one-line summary used instead of the differences, `kind` naming diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index c6d4140..369f22f 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -241,6 +241,37 @@ fn missing_file_as_empty_keeps_stdin_timestamp( .stdout_does_not_contain("1970"); } +// Only the first block of a file, as large as its preferred I/O size, is +// looked at to decide whether it is binary. +#[cfg(unix)] +#[test] +fn binary_detection_reads_first_block() { + use std::os::unix::fs::MetadataExt; + + let (at, ts) = at_and_ts!(); + let mut data = b"line\n".repeat(2000); + at.write_bytes("text", &data); + let block_size = usize::try_from(at.metadata("text").blksize()).unwrap(); + if !(1..data.len()).contains(&block_size) { + return; + } + data[block_size] = 0; + at.write_bytes("late_nul", &data); + data[block_size - 1] = 0; + at.write_bytes("early_nul", &data); + + // Each line is 5 bytes long + let line = block_size / 5 + 1; + for operands in [["late_nul", "text"], ["text", "late_nul"]] { + let result = ts.ucmd().args(&operands).fails_with_code(1); + assert!(result.stdout_str().starts_with(&format!("{line}c{line}\n"))); + } + ts.ucmd() + .args(&["early_nul", "text"]) + .fails_with_code(1) + .stdout_only("Binary files early_nul and text differ\n"); +} + #[test] fn binary_files() { let (at, ts) = at_and_ts!();