This is an automated email from the ASF dual-hosted git repository.

garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-csv.git


The following commit(s) were added to refs/heads/master by this push:
     new fb71dcb6 Escape-mode printer and parser mishandles value ending in 
multi-char delimiter prefix (f001).
fb71dcb6 is described below

commit fb71dcb6a1914664ce1bbd037199baf678b01f8c
Author: Gary Gregory <[email protected]>
AuthorDate: Mon Sep 7 07:17:32 2026 -0400

    Escape-mode printer and parser mishandles value ending in multi-char
    delimiter prefix (f001).
---
 src/changes/changes.xml                            |  1 +
 .../java/org/apache/commons/csv/CSVFormat.java     | 83 ++++++++++++++++------
 src/main/java/org/apache/commons/csv/Lexer.java    | 23 +++++-
 .../java/org/apache/commons/csv/CSVParserTest.java |  6 +-
 .../org/apache/commons/csv/CSVPrinterTest.java     | 75 +++++++++++++++++++
 .../java/org/apache/commons/csv/LexerTest.java     |  3 +-
 6 files changed, 164 insertions(+), 27 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index f3189baa..c7d17042 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -77,6 +77,7 @@
       <action type="fix" dev="ggregory" due-to="Gary Gregory">General Javadoc 
improvements.</action>
       <action type="fix" dev="ggregory" due-to="Gary Gregory, Naveed 
Khan">Validate CSVFormat invariants when deserializing (#632).</action>
       <action type="fix" dev="ggregory" due-to="Gary Gregory, saleem 
malik">Keep an escaped value that equals the null string (#626).</action>
+      <action type="fix" dev="ggregory" due-to="Gary Gregory">Escape-mode 
printer and parser mishandles value ending in multi-char delimiter prefix 
(f001).</action>
       <!-- ADD -->
       <action type="add" dev="ggregory" due-to="Gary Gregory, Indy, Sylvia van 
Os" issue="CSV-307">Add an "Android Compatibility" section to the web 
site.</action>
       <action type="add" dev="ggregory" due-to="Ruiqi Dong, Gary Gregory" 
issue="CSV-325">Add CSVParser.Builder.setByteOffset(long) (#604).</action>
diff --git a/src/main/java/org/apache/commons/csv/CSVFormat.java 
b/src/main/java/org/apache/commons/csv/CSVFormat.java
index 891c424b..63698174 100644
--- a/src/main/java/org/apache/commons/csv/CSVFormat.java
+++ b/src/main/java/org/apache/commons/csv/CSVFormat.java
@@ -1481,6 +1481,20 @@ public final class CSVFormat implements Serializable {
         return value == null || value.trim().isEmpty();
     }
 
+    /**
+     * Tests whether every character of the delimiter stays itself when 
individually escaped and read back. The letters used by control-character escape
+     * sequences ({@code r}, {@code n}, {@code t}, {@code b}, {@code f}) 
unescape to the control character, not to the letter, so a delimiter containing 
one of
+     * them cannot have a straddling prefix escaped losslessly and keeps the 
historical unescaped output.
+     */
+    private static boolean isDelimiterStraddleEscapable(final char[] 
delimiter) {
+        for (final char ch : delimiter) {
+            if (ch == 'r' || ch == 'n' || ch == 't' || ch == 'b' || ch == 'f') 
{
+                return false;
+            }
+        }
+        return true;
+    }
+
     /**
      * Returns true if the given character is a line break character.
      *
@@ -1702,25 +1716,7 @@ public final class CSVFormat implements Serializable {
      * delimiter yields {@code |||}, which the greedy lexer splits one 
character early). Such a value must be encapsulated so the field boundary is 
unambiguous.
      */
     private boolean endsWithDelimiterPrefix(final CharSequence charSeq, final 
char[] delimiter, final int delimiterLength) {
-        if (delimiterLength < 2) {
-            return false;
-        }
-        final int len = charSeq.length();
-        for (int start = Math.max(0, len - delimiterLength + 1); start < len; 
start++) {
-            boolean match = true;
-            for (int j = 0; j < delimiterLength; j++) {
-                final int idx = start + j;
-                final char c = idx < len ? charSeq.charAt(idx) : delimiter[idx 
- len];
-                if (c != delimiter[j]) {
-                    match = false;
-                    break;
-                }
-            }
-            if (match) {
-                return true;
-            }
-        }
-        return false;
+        return indexOfDelimiterPrefix(charSeq, delimiter, delimiterLength) >= 
0;
     }
 
     @Override
@@ -2092,6 +2088,33 @@ public final class CSVFormat implements Serializable {
         return result;
     }
 
+    /**
+     * Returns the index within {@code charSeq} where a straddling prefix of 
the delimiter begins, or -1 if the value does not end with one. Every character
+     * from the returned index to the end of the value takes part in a 
delimiter match that would start inside the value once the delimiter is 
appended, so an
+     * escaping printer must escape each of them individually. See {@link 
#endsWithDelimiterPrefix(CharSequence, char[], int)}.
+     */
+    private int indexOfDelimiterPrefix(final CharSequence charSeq, final 
char[] delimiter, final int delimiterLength) {
+        if (delimiterLength < 2) {
+            return -1;
+        }
+        final int len = charSeq.length();
+        for (int start = Math.max(0, len - delimiterLength + 1); start < len; 
start++) {
+            boolean match = true;
+            for (int j = 0; j < delimiterLength; j++) {
+                final int idx = start + j;
+                final char c = idx < len ? charSeq.charAt(idx) : delimiter[idx 
- len];
+                if (c != delimiter[j]) {
+                    match = false;
+                    break;
+                }
+            }
+            if (match) {
+                return start;
+            }
+        }
+        return -1;
+    }
+
     /**
      * Tests whether comments are supported by this format.
      *
@@ -2410,6 +2433,11 @@ public final class CSVFormat implements Serializable {
         final char quote = quoteSet ? getQuoteCharacter().charValue() : 0;
         final boolean commentMarkerSet = isCommentMarkerSet();
         final char commentChar = commentMarkerSet ? commentMarker.charValue() 
: 0; // Explicit unboxing is intentional
+        // A value ending in a straddling prefix of the delimiter must have 
every character of that prefix escaped:
+        // appended after the bare prefix, the delimiter would match one 
character early on read and shift the field
+        // boundary. This mirrors the endsWithDelimiterPrefix encapsulation in 
printWithQuotes.
+        final int prefixIndex = isDelimiterStraddleEscapable(delimArray) ? 
indexOfDelimiterPrefix(charSeq, delimArray, delimLength) : -1;
+        final int straddleStart = prefixIndex >= 0 ? prefixIndex : end;
         while (pos < end) {
             char c = charSeq.charAt(pos);
             final boolean isDelimiterStart = isDelimiter(c, charSeq, pos, 
delimArray, delimLength);
@@ -2417,7 +2445,7 @@ public final class CSVFormat implements Serializable {
             final boolean isLf = c == Constants.LF;
             // A leading comment marker would be read back as a comment, so 
escape it.
             final boolean isComment = commentMarkerSet && pos == 0 && c == 
commentChar;
-            if (isCr || isLf || c == escape || quoteSet && c == quote || 
isDelimiterStart || isComment) {
+            if (isCr || isLf || c == escape || quoteSet && c == quote || 
isDelimiterStart || isComment || pos >= straddleStart) {
                 // write out segment up until this char
                 if (pos > start) {
                     appendable.append(charSeq, start, pos);
@@ -2463,11 +2491,13 @@ public final class CSVFormat implements Serializable {
         final StringBuilder builder = new 
StringBuilder(IOUtils.DEFAULT_BUFFER_SIZE);
         int c;
         boolean firstChar = true;
+        boolean straddling = false;
+        final boolean straddleEscapable = 
isDelimiterStraddleEscapable(delimArray);
         final char[] lookAheadBuffer = new char[delimLength - 1];
         while (EOF != (c = bufferedReader.read())) {
             builder.append((char) c);
             Arrays.fill(lookAheadBuffer, (char) 0);
-            bufferedReader.peek(lookAheadBuffer);
+            final int lookAheadCount = Math.max(0, 
bufferedReader.peek(lookAheadBuffer));
             // Match the delimiter against the current character plus the 
look-ahead buffer only. Rebuilding the test
             // string from the whole accumulated builder made this loop O(n^2) 
for values without escapable characters.
             final String test = String.valueOf((char) c) + new 
String(lookAheadBuffer);
@@ -2477,7 +2507,16 @@ public final class CSVFormat implements Serializable {
             // A leading comment marker would be read back as a comment, so 
escape it.
             final boolean isComment = commentMarkerSet && firstChar && c == 
commentChar;
             firstChar = false;
-            if (isCr || isLf || c == escape || quoteSet && c == quote || 
isDelimiterStart || isComment) {
+            // Once the remaining stream is a straddling prefix of the 
delimiter, every remaining character must be
+            // escaped: appended after the bare prefix, the delimiter would 
match one character early on read and
+            // shift the field boundary. Such a prefix can only start once the 
end of the stream is within look-ahead
+            // range, so a short peek is a precondition. This mirrors the 
straddle handling in the CharSequence
+            // overload of printWithEscapes.
+            if (!straddling && straddleEscapable && lookAheadCount < 
delimLength - 1 &&
+                    indexOfDelimiterPrefix(String.valueOf((char) c) + new 
String(lookAheadBuffer, 0, lookAheadCount), delimArray, delimLength) == 0) {
+                straddling = true;
+            }
+            if (isCr || isLf || c == escape || quoteSet && c == quote || 
isDelimiterStart || isComment || straddling) {
                 // write out segment up until this char
                 if (pos > start) {
                     append(builder.substring(start, pos), appendable);
diff --git a/src/main/java/org/apache/commons/csv/Lexer.java 
b/src/main/java/org/apache/commons/csv/Lexer.java
index 12f1aa06..83c9c373 100644
--- a/src/main/java/org/apache/commons/csv/Lexer.java
+++ b/src/main/java/org/apache/commons/csv/Lexer.java
@@ -168,6 +168,21 @@ final class Lexer implements Closeable {
         return isLastTokenDelimiter;
     }
 
+    /**
+     * Tests whether the given character occurs in the delimiter.
+     *
+     * @param ch the character to test.
+     * @return true if the given character occurs in the delimiter.
+     */
+    private boolean isDelimiterChar(final int ch) {
+        for (final char delimiterChar : delimiter) {
+            if (ch == delimiterChar) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     /**
      * Tests if the given character indicates the end of the file.
      *
@@ -512,8 +527,12 @@ final class Lexer implements Closeable {
         case EOF:
             throw new CSVException("EOF while processing escape sequence");
         default:
-            // Now check for meta-characters
-            if (isMetaChar(ch)) {
+            // 1) Now check for meta-characters
+            // 2) An escaped delimiter character unescapes to the bare 
character: the printer escapes each character of
+            //    a multi-character delimiter individually, including a 
straddling prefix of the delimiter at the end of
+            //    an unquoted value. A fully escaped delimiter never reaches 
this method: isEscapeDelimiter() consumes it
+            //    first.
+            if (isMetaChar(ch) || isDelimiterChar(ch)) {
                 return ch;
             }
             // indicate unexpected char - available from in.getLastChar()
diff --git a/src/test/java/org/apache/commons/csv/CSVParserTest.java 
b/src/test/java/org/apache/commons/csv/CSVParserTest.java
index 50a6ae03..7e86fc00 100644
--- a/src/test/java/org/apache/commons/csv/CSVParserTest.java
+++ b/src/test/java/org/apache/commons/csv/CSVParserTest.java
@@ -1810,7 +1810,8 @@ class CSVParserTest {
         final CSVFormat csvFormat = 
CSVFormat.DEFAULT.builder().setDelimiter("[|]").setEscape('!').get();
         try (CSVParser csvParser = csvFormat.parse(new StringReader(source))) {
             CSVRecord csvRecord = csvParser.nextRecord();
-            assertEquals("a[|]b![|]c", csvRecord.get(0));
+            // "![" is an escaped delimiter character and unescapes to the 
bare "[".
+            assertEquals("a[|]b[|]c", csvRecord.get(0));
             assertEquals("xyz", csvRecord.get(1));
             csvRecord = csvParser.nextRecord();
             assertEquals("abc[abc]", csvRecord.get(0));
@@ -1901,7 +1902,8 @@ class CSVParserTest {
         final CSVFormat format = 
CSVFormat.DEFAULT.builder().setDelimiter("[|]").setEscape('!').get();
         try (CSVParser parser = format.parse(new 
StringReader("x![!|!]y![!|"))) {
             final CSVRecord record = parser.nextRecord();
-            assertEquals("x[|]y![!|", record.get(0));
+            // The truncated "![!|" is not a delimiter; its escaped delimiter 
characters unescape individually.
+            assertEquals("x[|]y[|", record.get(0));
             assertEquals(1, record.size());
         }
     }
diff --git a/src/test/java/org/apache/commons/csv/CSVPrinterTest.java 
b/src/test/java/org/apache/commons/csv/CSVPrinterTest.java
index f1f910bb..66064f44 100644
--- a/src/test/java/org/apache/commons/csv/CSVPrinterTest.java
+++ b/src/test/java/org/apache/commons/csv/CSVPrinterTest.java
@@ -71,6 +71,7 @@ import org.h2.tools.SimpleResultSet;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 /**
@@ -691,6 +692,80 @@ class CSVPrinterTest {
         assertEquals("\\\\", sw.toString());
     }
 
+    @Test
+    void testEscapeValueEndingWithDelimiterPrefix() throws IOException {
+        // No quoting available in escape mode, so a value ending in a 
straddling prefix of the multi-character
+        // delimiter must have that prefix escaped: appending the bare 
delimiter after "a|" yields "a|||", which
+        // reads back with the field boundary shifted one character early. 
Mirrors the endsWithDelimiterPrefix
+        // quoting fix for QuoteMode.MINIMAL.
+        final CSVFormat format = 
CSVFormat.DEFAULT.builder().setDelimiter("||").setQuote(null).setEscape('\\').get();
+        final StringWriter sw = new StringWriter();
+        try (CSVPrinter printer = new CSVPrinter(sw, format)) {
+            printer.printRecord("a|", "b");
+            printer.printRecord(new StringReader("a|"), new StringReader("b"));
+            // A delimiter prefix in the middle of a value cannot straddle the 
appended delimiter and is left alone.
+            printer.printRecord("a|b", "c");
+        }
+        final String string = sw.toString();
+        assertEquals("a\\|||b" + RECORD_SEPARATOR +
+                "a\\|||b" + RECORD_SEPARATOR +
+                "a|b||c" + RECORD_SEPARATOR, string);
+        // The emitted records must read back with the original field 
boundaries.
+        try (CSVParser parser = CSVParser.parse(string, format)) {
+            final List<CSVRecord> records = parser.getRecords();
+            assertEquals(3, records.size());
+            assertEquals("a|", records.get(0).get(0));
+            assertEquals("b", records.get(0).get(1));
+            assertEquals("a|", records.get(1).get(0));
+            assertEquals("b", records.get(1).get(1));
+            assertEquals("a|b", records.get(2).get(0));
+            assertEquals("c", records.get(2).get(1));
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "|||, a||, a\\|\\|",
+        "|||, ||, \\|\\|",
+        "|||, a|, a\\|",
+        "xyxy, axy, a\\x\\y",
+        "xyxy, ax, ax",
+        "[|], a[, a[",
+        "|||, a||z, a||z"
+    })
+    void testEscapeValueEndingWithLongDelimiterPrefix(final String delimiter, 
final String value, final String escaped) throws IOException {
+        final CSVFormat format = 
CSVFormat.DEFAULT.builder().setDelimiter(delimiter).setQuote(null).setEscape('\\').get();
+        final StringWriter sw = new StringWriter();
+        try (CSVPrinter printer = new CSVPrinter(sw, format)) {
+            printer.printRecord(value, "z");
+            printer.printRecord(new StringReader(value), new 
StringReader("z"));
+        }
+        final String expectedRecord = escaped + delimiter + "z" + 
RECORD_SEPARATOR;
+        assertEquals(expectedRecord + expectedRecord, sw.toString());
+        try (CSVParser parser = CSVParser.parse(sw.toString(), format)) {
+            final List<CSVRecord> records = parser.getRecords();
+            assertEquals(2, records.size());
+            for (final CSVRecord record : records) {
+                assertArrayEquals(new String[] { value, "z" }, 
record.values());
+            }
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "rr", "nn", "tt", "bb", "ff" })
+    void testEscapeValueEndingWithUnescapableDelimiterPrefix(final String 
delimiter) throws IOException {
+        // Escaping these letters would turn them into control characters. 
Preserve the historical output.
+        final CSVFormat format = 
CSVFormat.DEFAULT.builder().setDelimiter(delimiter).setQuote(null).setEscape('\\').get();
+        final String value = "a" + delimiter.charAt(0);
+        final StringWriter sw = new StringWriter();
+        try (CSVPrinter printer = new CSVPrinter(sw, format)) {
+            printer.printRecord(value, "z");
+            printer.printRecord(new StringReader(value), new 
StringReader("z"));
+        }
+        final String expectedRecord = value + delimiter + "z" + 
RECORD_SEPARATOR;
+        assertEquals(expectedRecord + expectedRecord, sw.toString());
+    }
+
     @Test
     void testExcelPrintAllArrayOfArrays() throws IOException {
         final StringWriter sw = new StringWriter();
diff --git a/src/test/java/org/apache/commons/csv/LexerTest.java 
b/src/test/java/org/apache/commons/csv/LexerTest.java
index a76f6e51..3d387f01 100644
--- a/src/test/java/org/apache/commons/csv/LexerTest.java
+++ b/src/test/java/org/apache/commons/csv/LexerTest.java
@@ -436,7 +436,8 @@ class LexerTest {
     void testPartialEscapedMultiCharacterDelimiterAtEOF() throws IOException {
         final CSVFormat format = 
CSVFormat.DEFAULT.builder().setDelimiter("[|]").setEscape('!').get();
         try (Lexer lexer = createLexer("x![!|!]y![!|", format)) {
-            assertNextToken(EOF, "x[|]y![!|", lexer);
+            // The truncated "![!|" is not a delimiter; its escaped delimiter 
characters unescape individually.
+            assertNextToken(EOF, "x[|]y[|", lexer);
         }
     }
 

Reply via email to