This change is performance neutral as tested with:
 $ yes $(yes eeeaae | head -n10K | paste -s -d,) | head -n10K > ll.in
 $ hyperfine src/cut{,.orig}' -f10 -w ll.in >/dev/null'

* gl/lib/mbbuf.h:
* tests/cut/mb-short-read.sh: A new test file.
* tests/local.mk: Reference the new test.
* NEWS: Mention the bug fix.

Discovered by Anthropic's OSS Scanner, as bug ANT-2026-JSCMJCN2.
---
 NEWS                       |  5 ++++
 gl/lib/mbbuf.h             | 20 ++++++++++----
 tests/cut/mb-short-read.sh | 53 ++++++++++++++++++++++++++++++++++++++
 tests/local.mk             |  1 +
 4 files changed, 74 insertions(+), 5 deletions(-)
 create mode 100755 tests/cut/mb-short-read.sh

diff --git a/NEWS b/NEWS
index c64f7b481..184f51012 100644
--- a/NEWS
+++ b/NEWS
@@ -95,6 +95,11 @@ GNU coreutils NEWS                                    -*- 
outline -*-
   146077762747232966952776282887314548659.
   [bugs introduced in coreutils-8.20]
 
+  cut, expand, fold, and unexpand no longer treat a multi-byte character
+  as separate bytes when a read() from a pipe or terminal returns only
+  part of the character.
+  [bug introduced in coreutils-9.11]
+
   'fold' no longer prematurely wraps lines with a wide character towards
   the start of a line, followed by multiple backspaces.  Also backspaces
   after a tab character are accounted for correctly.
diff --git a/gl/lib/mbbuf.h b/gl/lib/mbbuf.h
index 626dc737e..e640098a8 100644
--- a/gl/lib/mbbuf.h
+++ b/gl/lib/mbbuf.h
@@ -123,15 +123,25 @@ mbbuf_fill (mbbuf_t *mbbuf)
 {
   idx_t available = mbbuf_avail (mbbuf);
 
-  if (available == 0)
-    return mbbuf_topup (mbbuf);
+  /* Short-circuit the common case.  */
+  if (MCEL_LEN_MAX <= available)
+    return available;
 
-  if (available < MCEL_LEN_MAX && ! mbbuf->eof)
+  if (available == 0)
+    available = mbbuf_topup (mbbuf);
+
+  /* A single read() may return only part of a character, e.g., from a pipe
+     or terminal.  Keep reading until the character at the current offset
+     is complete, or enough bytes are available to decide, or at EOF.
+     Note mcel_scan does not distinguish invalid from incomplete sequences,
+     so invalid bytes can also cause further reads and delay output.  */
+  while (0 < available && available < MCEL_LEN_MAX && ! mbbuf->eof)
     {
       mcel_t g = mcel_scan (mbbuf->buffer + mbbuf->offset,
                             mbbuf->buffer + mbbuf->length);
-      if (g.err)
-        return mbbuf_topup (mbbuf);
+      if (! g.err)
+        break;
+      available = mbbuf_topup (mbbuf);
     }
 
   return available;
diff --git a/tests/cut/mb-short-read.sh b/tests/cut/mb-short-read.sh
new file mode 100755
index 000000000..9edc5278c
--- /dev/null
+++ b/tests/cut/mb-short-read.sh
@@ -0,0 +1,53 @@
+#!/bin/sh
+# Ensure a multi-byte character split across read() calls
+# is not processed as separate bytes.
+
+# Copyright (C) 2026 Free Software Foundation, Inc.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+. "${srcdir=.}/tests/init.sh"; path_prepend_ ./src
+print_ver_ cut fold expand unexpand
+
+{ test -z "$LOCALE_FR_UTF8" || test "$LOCALE_FR_UTF8" = none; } &&
+  skip_ 'French UTF-8 locale not available'
+export LC_ALL="$LOCALE_FR_UTF8"
+
+# Write each byte of U+20AC separately, so that each read() returns one.
+euro_in_pieces ()
+{
+  printf '\342'; sleep .1; printf '\202'; sleep .1; printf '\254'; printf "$1"
+}
+
+printf '\342\202\254ab\n' > exp || framework_failure_
+euro_in_pieces 'ab\n' | cut -c1-3 > out || fail=1
+compare exp out || fail=1
+
+printf '\n' > exp || framework_failure_
+euro_in_pieces 'ab\n' | cut -b1-2 -n > out || fail=1
+compare exp out || fail=1
+
+printf '\342\202\254a\nb\n' > exp || framework_failure_
+euro_in_pieces 'ab\n' | fold -w2 > out || fail=1
+compare exp out || fail=1
+
+printf '\342\202\254       b\n' > exp || framework_failure_
+euro_in_pieces '\tb\n' | expand > out || fail=1
+compare exp out || fail=1
+
+printf '\342\202\254\tb\n' > exp || framework_failure_
+euro_in_pieces '       b\n' | unexpand -a > out || fail=1
+compare exp out || fail=1
+
+Exit $fail
diff --git a/tests/local.mk b/tests/local.mk
index 2199b39c2..0a49022a9 100644
--- a/tests/local.mk
+++ b/tests/local.mk
@@ -319,6 +319,7 @@ all_tests =                                 \
   tests/misc/coreutils.sh                      \
   tests/cut/cut.pl                             \
   tests/cut/mb-non-utf8.sh                     \
+  tests/cut/mb-short-read.sh                   \
   tests/cut/bounded-memory.sh                  \
   tests/cut/cut-huge-range.sh                  \
   tests/wc/mb-non-utf8.sh                      \
-- 
2.56.0


Reply via email to