From 8f085c8e5770b5233a9d72b7af1f95855c0ddd72 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 18 Aug 2026 22:44:07 +0200 Subject: [PATCH] ptx: accept invalid UTF-8 input and trailing backslash regexps ptx aborted with "stream did not contain valid UTF-8" on input that is not valid UTF-8, while GNU ptx operates on bytes and processes it fine. Input is now read as bytes and decoded lossily. Additionally, a regexp ending in a lone backslash (e.g. -S 'foo\') was rejected by the regex crate as an incomplete escape sequence, whereas GNU treats it as a literal backslash. Such a trailing backslash is now doubled before compiling the pattern, for both -S and -W. Should make test tests/ptx/ptx-overrun.sh pass https://github.com/uutils/coreutils/issues/13841 --- src/uu/ptx/src/ptx.rs | 30 ++++++++++++++++++++++++------ tests/by-util/test_ptx.rs | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/uu/ptx/src/ptx.rs b/src/uu/ptx/src/ptx.rs index a92d78db105..759ddd2fdf5 100644 --- a/src/uu/ptx/src/ptx.rs +++ b/src/uu/ptx/src/ptx.rs @@ -22,6 +22,18 @@ use uucore::error::{FromIo, UResult, USimpleError, UUsageError}; use uucore::format_usage; use uucore::translate; +/// GNU's regex engine treats a trailing lone backslash as a literal backslash, +/// while the `regex` crate rejects it as an incomplete escape sequence. Double +/// it so that such patterns keep working instead of erroring out. +fn escape_trailing_backslash(pattern: &str) -> String { + let trailing = pattern.chars().rev().take_while(|&c| c == '\\').count(); + if trailing % 2 == 1 { + format!("{pattern}\\") + } else { + pattern.to_owned() + } +} + #[derive(Debug, PartialEq)] enum OutFormat { Dumb, @@ -149,7 +161,7 @@ impl WordFilter { matches .get_one::(options::WORD_REGEXP) .filter(|v| !v.is_empty()) - .map(ToOwned::to_owned) + .map(|v| escape_trailing_backslash(v)) } else { None }; @@ -196,7 +208,10 @@ fn get_config(matches: &mut clap::ArgMatches) -> UResult { config.format = OutFormat::Roff; "[^ \t\n]+".clone_into(&mut config.context_regex); } - if let Some(regex) = matches.remove_one::(options::SENTENCE_REGEXP) { + if let Some(regex) = matches + .remove_one::(options::SENTENCE_REGEXP) + .map(|r| escape_trailing_backslash(&r)) + { // TODO: The regex crate used here is not fully compatible with GNU's regex implementation. // For example, it does not support backreferences. // In the future, we might want to switch to the onig crate (like expr does) for better compatibility. @@ -295,17 +310,20 @@ fn read_lines( sentence_splitter: Option<&Regex>, reader: &mut dyn BufRead, ) -> std::io::Result> { - if let Some(re) = sentence_splitter { - let mut buffer = String::new(); - reader.read_to_string(&mut buffer)?; + // GNU ptx works on bytes, so invalid UTF-8 input must not be an error. + // Read everything and replace invalid sequences instead of failing. + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes)?; + let buffer = String::from_utf8_lossy(&bytes); + if let Some(re) = sentence_splitter { Ok(re .split(&buffer) .map(|s| s.replace('\n', " ")) // ptx behavior: newlines become spaces inside sentences .filter(|s| !s.is_empty()) // remove empty sentences .collect()) } else { - reader.lines().collect() + Ok(buffer.lines().map(ToOwned::to_owned).collect()) } } diff --git a/tests/by-util/test_ptx.rs b/tests/by-util/test_ptx.rs index f7717869d96..d53916dbe00 100644 --- a/tests/by-util/test_ptx.rs +++ b/tests/by-util/test_ptx.rs @@ -462,3 +462,26 @@ fn test_missing_file_error_contains_filename() { .fails() .stderr_is("ptx: 'zxc': No such file or directory\n"); } + +#[test] +fn test_sentence_regex_trailing_backslash() { + // GNU treats a trailing lone backslash as a literal one instead of erroring. + new_ucmd!() + .args(&["-S", "paris\\"]) + .pipe_in("") + .succeeds() + .no_output(); + new_ucmd!() + .args(&["-S", "london\\\\\\"]) + .pipe_in("") + .succeeds() + .no_output(); +} + +#[test] +fn test_invalid_utf8_input_is_not_an_error() { + new_ucmd!() + .pipe_in(b"ab\xFFcd\n".to_vec()) + .succeeds() + .no_stderr(); +}