From bd7e3fe75f41409eea713bf12dc250ca35ecb190 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Tue, 11 Aug 2026 10:44:07 -0700 Subject: [PATCH] xargs: don't emit an empty argument for a trailing blank When input ended with a blank immediately before its final newline, xargs passed one extra empty argument to the command: $ printf 'aaa \nbbb \n' | xargs printf '[%s]' [aaa][bbb][] # was [aaa][bbb] # GNU findutils 4.10.0, and now WhitespaceDelimitedArgumentReader::next used one `result.is_empty()` check to answer two different questions. The whitespace branch broke out of the loop only if `result` was non-empty, so a delimiter seen before any token was skipped -- correct for blanks, but it left "we are mid-token" indistinguishable from "we have not started one". The EOF branch then used `i == 0` as a proxy for "nothing was consumed", which is false for a final call that consumes only the trailing newline, so it flushed a zero-length token. Input ending without a newline happened to work because nothing was left to consume. Track that state explicitly with an `in_argument` flag, mirroring `seen_arg` in GNU's read_line, and test `result.is_empty()` at EOF. A delimiter now ends an argument only when we are inside one, and EOF flushes only a non-empty buffer. This also fixes a second symptom of the same conflation: quoted empty arguments were dropped mid-stream, since quotes start an argument without contributing any bytes. `printf '"" x\n'` gave [x] and now gives [][x], again matching GNU. An unterminated `x ''` at end of input still yields just [x], as GNU's `if (p == linebuf) return -1` does. -I/-i is unaffected: with a replace string and no explicit delimiter, xargs uses the newline-delimited reader instead. Co-Authored-By: Claude Opus 5 (1M context) --- src/xargs/mod.rs | 85 ++++++++++++++++++++++++++++++++++++++++++--- tests/test_xargs.rs | 28 +++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/src/xargs/mod.rs b/src/xargs/mod.rs index 36c695e8..4dbce3a3 100644 --- a/src/xargs/mod.rs +++ b/src/xargs/mod.rs @@ -581,6 +581,11 @@ where let mut result = vec![]; let mut terminated_by_newline = false; + // Whether we're inside an argument. This is *not* the same as + // `!result.is_empty()`: quotes and escapes start an argument without + // necessarily contributing any bytes to it, so `''` is a genuine empty + // argument, while a run of blanks is just a delimiter. + let mut in_argument = false; let mut pending = vec![]; std::mem::swap(&mut pending, &mut self.pending); @@ -606,7 +611,9 @@ where format!("Unterminated quote: {q}"), )); } - if i == 0 { + // Anything we skipped over was a delimiter, not an + // argument, so there is nothing left to emit. + if result.is_empty() { return Ok(None); } pending.clear(); @@ -624,15 +631,24 @@ where result.push(c); escape = None; } - (None, c @ (b'"' | b'\'')) => escape = Some(Escape::Quote(c)), - (None, b'\\') => escape = Some(Escape::Slash), + (None, c @ (b'"' | b'\'')) => { + in_argument = true; + escape = Some(Escape::Quote(c)); + } + (None, b'\\') => { + in_argument = true; + escape = Some(Escape::Slash); + } (None, c) if c.is_ascii_whitespace() => { - if !result.is_empty() { + if in_argument { terminated_by_newline = c == b'\n'; break; } } - (None, c) => result.push(c), + (None, c) => { + in_argument = true; + result.push(c); + } } i += 1; @@ -1490,6 +1506,65 @@ mod tests { assert_eq!(reader.next().unwrap(), None); } + #[test] + fn test_whitespace_delimited_reader_trailing_blanks() { + // A run of blanks before the final newline is a delimiter, not an + // empty argument, no matter where in the input it appears. + for input in [ + &b"aaa \nbbb \n"[..], + &b"aaa\nbbb \n"[..], + &b"aaa \nbbb\n"[..], + &b"aaa \nbbb \n \t \n"[..], + ] { + let mut reader = + WhitespaceDelimitedArgumentReader::new(ChunkReader::new(vec![Chunk::Data(input)])); + assert_eq!(reader.next().unwrap().unwrap().arg, "aaa", "{input:?}"); + assert_eq!(reader.next().unwrap().unwrap().arg, "bbb", "{input:?}"); + assert_eq!(reader.next().unwrap(), None, "{input:?}"); + } + + // Blanks are only a soft terminator, so the newline that follows them + // does not end the logical line (this matters for -L). + let mut reader = + WhitespaceDelimitedArgumentReader::new(ChunkReader::new(vec![Chunk::Data(b"aaa \n")])); + assert_eq!(reader.next().unwrap().unwrap(), make_arg_soft("aaa")); + assert_eq!(reader.next().unwrap(), None); + + let mut reader = WhitespaceDelimitedArgumentReader::new(ChunkReader::new(vec![ + Chunk::Data(b"aaa "), + Chunk::Error(io::ErrorKind::Interrupted), + Chunk::Data(b" \t "), + ])); + assert_eq!(reader.next().unwrap().unwrap(), make_arg_soft("aaa")); + assert_eq!(reader.next().unwrap(), None); + + let mut reader = + WhitespaceDelimitedArgumentReader::new(ChunkReader::new(vec![Chunk::Data(b" \n\t\n")])); + assert_eq!(reader.next().unwrap(), None); + } + + #[test] + fn test_whitespace_delimited_reader_quoted_empty_arguments() { + // Quotes start an argument even when they contribute no bytes, so an + // empty quoted string is a real (empty) argument. + let mut reader = WhitespaceDelimitedArgumentReader::new(ChunkReader::new(vec![ + Chunk::Data(b"'' x \"\"\n"), + Chunk::Data(b"y ''\n"), + ])); + assert_eq!(reader.next().unwrap().unwrap(), make_arg_soft("")); + assert_eq!(reader.next().unwrap().unwrap(), make_arg_soft("x")); + assert_eq!(reader.next().unwrap().unwrap(), make_arg_hard("")); + assert_eq!(reader.next().unwrap().unwrap(), make_arg_soft("y")); + assert_eq!(reader.next().unwrap().unwrap(), make_arg_hard("")); + assert_eq!(reader.next().unwrap(), None); + + // ...but an unterminated one at end of input is dropped, as GNU does. + let mut reader = + WhitespaceDelimitedArgumentReader::new(ChunkReader::new(vec![Chunk::Data(b"x ''")])); + assert_eq!(reader.next().unwrap().unwrap(), make_arg_soft("x")); + assert_eq!(reader.next().unwrap(), None); + } + #[test] fn test_eof_argument_reader() { let filter = String::from("def"); diff --git a/tests/test_xargs.rs b/tests/test_xargs.rs index 5d04336f..45e9a571 100644 --- a/tests/test_xargs.rs +++ b/tests/test_xargs.rs @@ -25,6 +25,34 @@ fn xargs_basics() { .stdout_only("abc def ghi i j \"k\n"); } +#[test] +fn xargs_trailing_blanks() { + // A blank before the final newline is a delimiter, so it must not produce + // a trailing empty argument (GNU findutils behaves the same way). + for input in [ + "aaa \nbbb \n", + "aaa\nbbb \n", + "aaa \nbbb\n", + "aaa \nbbb \n", + ] { + ucmd() + .arg("-n1") + .pipe_in(input) + .succeeds() + .stdout_only("aaa\nbbb\n"); + } +} + +#[test] +fn xargs_quoted_empty_argument() { + // An empty quoted string is a real argument, unlike a run of blanks. + ucmd() + .args(&["-n1"]) + .pipe_in("'' aaa \"\"\n") + .succeeds() + .stdout_only("\naaa\n\n"); +} + #[test] fn xargs_null() { ucmd()