diff --git a/src/context_diff.rs b/src/context_diff.rs index 334a661..f0f2710 100644 --- a/src/context_diff.rs +++ b/src/context_diff.rs @@ -8,7 +8,9 @@ use std::io::Write; use crate::params::Params; use crate::utils::do_write_line; -use crate::utils::{get_modification_time, is_posix_locale, FunctionLineFinder, TimestampStyle}; +use crate::utils::{ + get_modification_time, is_posix_locale, write_file_header, FunctionLineFinder, TimestampStyle, +}; #[derive(Debug, PartialEq)] pub enum DiffLine { @@ -323,14 +325,9 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { }; let from_modified_time = get_modification_time(¶ms.from.to_string_lossy(), style); let to_modified_time = get_modification_time(¶ms.to.to_string_lossy(), style); - let mut output = format!( - "*** {0}\t{1}\n--- {2}\t{3}\n", - params.from.to_string_lossy(), - from_modified_time, - params.to.to_string_lossy(), - to_modified_time - ) - .into_bytes(); + let mut output = Vec::new(); + write_file_header(&mut output, "***", ¶ms.from, &from_modified_time); + write_file_header(&mut output, "---", ¶ms.to, &to_modified_time); let diff_results = make_diff(expected, actual, params.context_count, params.brief); if diff_results.is_empty() { return Vec::new(); diff --git a/src/dir_diff.rs b/src/dir_diff.rs index d389bf4..a7a8505 100644 --- a/src/dir_diff.rs +++ b/src/dir_diff.rs @@ -14,7 +14,7 @@ use std::path::{Path, PathBuf}; use crate::diff::{compare_files, Outcome}; use crate::params::{MissingFile, Params}; -use crate::utils::report_failure_to_read_input_file; +use crate::utils::{quote_file_name, quote_shell_argument, report_failure_to_read_input_file}; /// Compares the directories `params.from` and `params.to`. `option_args` are /// the command line options, repeated in the header printed before the @@ -177,14 +177,13 @@ impl DirectoryWalker<'_> { ..self.params.clone() }; let mut header = b"diff".to_vec(); - for arg in self - .option_args - .iter() - .map(OsString::as_os_str) - .chain([from, to]) - { + for arg in self.option_args { header.push(b' '); - header.extend_from_slice(arg.as_encoded_bytes()); + header.extend_from_slice("e_shell_argument(arg.as_encoded_bytes())); + } + for path in [from, to] { + header.push(b' '); + header.extend_from_slice("e_file_name(path.as_encoded_bytes())); } header.push(b'\n'); compare_files(¶ms, Some(&header)) diff --git a/src/unified_diff.rs b/src/unified_diff.rs index 094b4e1..e30da1d 100644 --- a/src/unified_diff.rs +++ b/src/unified_diff.rs @@ -8,7 +8,7 @@ use std::io::Write; use crate::params::Params; use crate::utils::do_write_line; -use crate::utils::{get_modification_time, FunctionLineFinder, TimestampStyle}; +use crate::utils::{get_modification_time, write_file_header, FunctionLineFinder, TimestampStyle}; #[derive(Debug, PartialEq)] pub enum DiffLine { @@ -245,14 +245,9 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { let from_modified_time = get_modification_time(¶ms.from.to_string_lossy(), TimestampStyle::Iso); let to_modified_time = get_modification_time(¶ms.to.to_string_lossy(), TimestampStyle::Iso); - let mut output = format!( - "--- {0}\t{1}\n+++ {2}\t{3}\n", - params.from.to_string_lossy(), - from_modified_time, - params.to.to_string_lossy(), - to_modified_time - ) - .into_bytes(); + let mut output = Vec::new(); + write_file_header(&mut output, "---", ¶ms.from, &from_modified_time); + write_file_header(&mut output, "+++", ¶ms.to, &to_modified_time); let diff_results = make_diff(expected, actual, params.context_count, params.brief); if diff_results.is_empty() { return Vec::new(); diff --git a/src/utils.rs b/src/utils.rs index 8e35bc9..0265109 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -5,7 +5,11 @@ use crate::bre::Bre; use regex::Regex; -use std::{ffi::OsString, io::Write}; +use std::{ + borrow::Cow, + ffi::{OsStr, OsString}, + io::Write, +}; use unicode_width::UnicodeWidthStr; /// Replace tabs by spaces in the input line. @@ -53,6 +57,96 @@ pub fn do_write_line( } } +/// Quotes a file name for the headers of the diff output: a name containing +/// spaces, double quotes, backslashes, control characters or non-ASCII bytes +/// is written between double quotes with C-style escapes, other names are +/// written as is. On Windows, backslashes separate path components and are +/// left alone. +#[must_use] +pub fn quote_file_name(name: &[u8]) -> Cow<'_, [u8]> { + const SPECIAL_BACKSLASH: bool = cfg!(not(windows)); + let needs_quotes = |byte: u8| { + byte <= b' ' || byte == b'"' || (byte == b'\\' && SPECIAL_BACKSLASH) || byte >= 0x80 + }; + if !name.iter().copied().any(needs_quotes) { + return Cow::Borrowed(name); + } + let mut quoted = Vec::with_capacity(name.len() + 8); + quoted.push(b'"'); + for &byte in name { + let escape = match byte { + b'"' => b'"', + b'\\' if SPECIAL_BACKSLASH => b'\\', + 0x07 => b'a', + 0x08 => b'b', + b'\t' => b't', + b'\n' => b'n', + 0x0b => b'v', + 0x0c => b'f', + b'\r' => b'r', + b' ' => { + quoted.push(b' '); + continue; + } + byte if needs_quotes(byte) => { + quoted.extend_from_slice(format!("\\{byte:03o}").as_bytes()); + continue; + } + byte => { + quoted.push(byte); + continue; + } + }; + quoted.extend_from_slice(&[b'\\', escape]); + } + quoted.push(b'"'); + Cow::Owned(quoted) +} + +/// Appends a file header line of the context or unified formats, such as +/// `--- name\ttimestamp`, to `output`. +pub fn write_file_header(output: &mut Vec, marker: &str, name: &OsStr, timestamp: &str) { + output.extend_from_slice(marker.as_bytes()); + output.push(b' '); + output.extend_from_slice("e_file_name(name.as_encoded_bytes())); + output.push(b'\t'); + output.extend_from_slice(timestamp.as_bytes()); + output.push(b'\n'); +} + +/// Quotes a command line argument the way a shell would need it, for the +/// headers that repeat the options of a directory comparison. +#[must_use] +pub fn quote_shell_argument(arg: &[u8]) -> Cow<'_, [u8]> { + const SPECIAL: &[u8] = b"^*?!=()|&;<>`\\'\"$["; + let is_special = |byte: &u8| *byte <= b' ' || *byte == 0x7f || SPECIAL.contains(byte); + let special_start = matches!(arg.first(), Some(b'~' | b'#')); + if !special_start && !arg.iter().any(is_special) && !arg.is_empty() { + return Cow::Borrowed(arg); + } + let mut quoted = Vec::with_capacity(arg.len() + 2); + let only_single_quotes = arg + .iter() + .filter(|byte| is_special(byte)) + .all(|&byte| byte == b'\''); + if arg.contains(&b'\'') && only_single_quotes { + quoted.push(b'"'); + quoted.extend_from_slice(arg); + quoted.push(b'"'); + } else { + quoted.push(b'\''); + for &byte in arg { + if byte == b'\'' { + quoted.extend_from_slice(b"'\\''"); + } else { + quoted.push(byte); + } + } + quoted.push(b'\''); + } + Cow::Owned(quoted) +} + /// Finds the lines shown after hunk headers by `-F` and `-p`: the nearest /// line of the old file preceding a hunk that matches one of the patterns. pub struct FunctionLineFinder<'a> { @@ -309,6 +403,54 @@ mod tests { } } + mod quoting { + use super::*; + use pretty_assertions::assert_eq; + + fn quoted_name(name: &[u8]) -> String { + String::from_utf8(quote_file_name(name).into_owned()).unwrap() + } + + fn quoted_arg(arg: &str) -> String { + String::from_utf8(quote_shell_argument(arg.as_bytes()).into_owned()).unwrap() + } + + #[test] + fn file_names() { + assert_eq!(quoted_name(b"src/main.rs"), "src/main.rs"); + assert_eq!(quoted_name(b"why?*$'#~"), "why?*$'#~"); + assert_eq!(quoted_name(b"two words"), r#""two words""#); + assert_eq!(quoted_name(b"say \"hi\""), r#""say \"hi\"""#); + if cfg!(windows) { + assert_eq!(quoted_name(b"C:\\temp"), r"C:\temp"); + assert_eq!(quoted_name(b"C:\\my docs"), r#""C:\my docs""#); + } else { + assert_eq!(quoted_name(b"C:\\temp"), r#""C:\\temp""#); + } + assert_eq!( + quoted_name(b"\x07\x08\t\n\x0b\x0c\r"), + r#""\a\b\t\n\v\f\r""# + ); + assert_eq!(quoted_name(b"\x1bx\x7f"), "\"\\033x\x7f\""); + assert_eq!(quoted_name("caf\u{e9}".as_bytes()), r#""caf\303\251""#); + assert_eq!(quoted_name(b"\xfe"), r#""\376""#); + } + + #[test] + fn shell_arguments() { + assert_eq!(quoted_arg("-u"), "-u"); + assert_eq!(quoted_arg("a,b:c@d%e+f{g}h]/i.j"), "a,b:c@d%e+f{g}h]/i.j"); + assert_eq!(quoted_arg("x~#"), "x~#"); + assert_eq!(quoted_arg("~x"), "'~x'"); + assert_eq!(quoted_arg("#x"), "'#x'"); + assert_eq!(quoted_arg("^fn (main|init)"), "'^fn (main|init)'"); + assert_eq!(quoted_arg("--width=9"), "'--width=9'"); + assert_eq!(quoted_arg(""), "''"); + assert_eq!(quoted_arg("don't"), "\"don't\""); + assert_eq!(quoted_arg("don't $pay"), "'don'\\''t $pay'"); + } + } + mod write_line { use super::*; use pretty_assertions::assert_eq; diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index e142903..2b29278 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -797,3 +797,56 @@ fn function_lines() -> Result<(), Box> { .stderr("'\\(': Unmatched ( or \\(\n"); Ok(()) } + +#[cfg(unix)] +#[test] +fn quoted_file_names() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let dir = tmp_dir.path().to_str().unwrap(); + std::fs::write(tmp_dir.path().join("my notes"), "draft\n")?; + std::fs::write(tmp_dir.path().join("final\tv2"), "done\n")?; + std::fs::write(tmp_dir.path().join("plain$name"), "done\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.env("TZ", "UTC0") + .arg("diff") + .arg("-u") + .arg(format!("{dir}/my notes")) + .arg(format!("{dir}/final\tv2")); + cmd.assert() + .code(predicate::eq(1)) + .stdout(predicate::str::is_match(format!( + "^--- \"{dir}/my notes\"\t[^\n]*\n\\+\\+\\+ \"{dir}/final\\\\tv2\"\t" + ))?); + + // Names without special characters are left alone. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("-c") + .arg(format!("{dir}/my notes")) + .arg(format!("{dir}/plain$name")); + cmd.assert() + .code(predicate::eq(1)) + .stdout(predicate::str::contains(format!( + "\n--- {dir}/plain$name\t" + ))); + + // The diff -r header quotes the options for the shell and the names as + // in the other headers. + for (side, content) in [("one", "a\n"), ("two", "b\n")] { + std::fs::create_dir(tmp_dir.path().join(side))?; + std::fs::write(tmp_dir.path().join(side).join("my file"), content)?; + } + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.current_dir(&tmp_dir) + .arg("diff") + .arg("-r") + .arg("-F") + .arg("my func") + .arg("one") + .arg("two"); + cmd.assert() + .code(predicate::eq(1)) + .stdout("diff -r -F 'my func' \"one/my file\" \"two/my file\"\n1c1\n< a\n---\n> b\n"); + Ok(()) +}