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-codec.git


The following commit(s) were added to refs/heads/master by this push:
     new 089f9045 Fix quoted-printable line break decoding
089f9045 is described below

commit 089f90450fd1f8acafad3fe626b9e9425aff5a3c
Author: Gary Gregory <[email protected]>
AuthorDate: Sat Sep 19 04:53:18 2026 -0700

    Fix quoted-printable line break decoding
    
    Preserve hard CRLF line breaks and unpaired CR/LF bytes. Require the
    complete =CRLF sequence for soft breaks and reject incomplete =CR
    escapes.
    
    Clarify decoding limitations and compatibility changes, including
    QCodec.
    
    Add regression tests covering both encoding modes,
    line-break boundaries, and shared QCodec decoding paths.
---
 src/changes/changes.xml                            |  1 +
 .../java/org/apache/commons/codec/net/QCodec.java  |  7 +++
 .../commons/codec/net/QuotedPrintableCodec.java    | 33 ++++++++---
 .../org/apache/commons/codec/net/QCodecTest.java   | 16 ++++++
 .../codec/net/QuotedPrintableCodecTest.java        | 67 ++++++++++++++++++----
 5 files changed, 106 insertions(+), 18 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index a78da67f..320900e9 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -64,6 +64,7 @@ The <action> type attribute can be add,update,fix,remove.
       <action type="fix" dev="ggregory" due-to="Gary Gregory">Restrict Hex 
decoding to ASCII hexadecimal characters.</action>
       <action type="fix" dev="ggregory" due-to="Gary Gregory">Validate 
BinaryCodec input while preserving leading-bit truncation.</action>
       <action type="fix" dev="ggregory" due-to="Gary Gregory">Reject oversized 
Beider-Morse input before language guessing.</action>
+      <action type="fix" dev="ggregory" due-to="Gary 
Gregory">QuotedPrintableCodec decoding now preserves hard CRLF line breaks and, 
leniently, unpaired CR and LF bytes instead of discarding them. Soft line 
breaks require the full =CRLF sequence; previously accepted =CR without LF now 
throws DecoderException. This affects both constructor modes and QCodec, which 
shares the decoder.</action>
       <!-- ADD -->
       <action type="add" dev="ggregory" due-to="Gary Gregory">Add and use 
PhoneticEngine.Builder and deprecate old constructors.</action>
       <action type="add" dev="ggregory" due-to="Gary Gregory">Add 
BeiderMorseEncoder.Builder and deprecate old constructor.</action>
diff --git a/src/main/java/org/apache/commons/codec/net/QCodec.java 
b/src/main/java/org/apache/commons/codec/net/QCodec.java
index 91077414..c0b004b9 100644
--- a/src/main/java/org/apache/commons/codec/net/QCodec.java
+++ b/src/main/java/org/apache/commons/codec/net/QCodec.java
@@ -101,6 +101,7 @@ public class QCodec extends RFC1522Codec implements 
StringEncoder, StringDecoder
         PRINTABLE_CHARS.set('}');
         PRINTABLE_CHARS.set('~');
     }
+
     private static final byte UNDERSCORE = 95;
 
     private boolean encodeBlanks;
@@ -165,6 +166,12 @@ public class QCodec extends RFC1522Codec implements 
StringEncoder, StringDecoder
      * Decodes a quoted-printable string into its original form. Escaped 
characters are converted back to their original
      * representation.
      *
+     * <p>
+     * Uses {@link QuotedPrintableCodec#decodeQuotedPrintable(byte[])} to 
decode the encoded text. Since 1.23.0, unescaped CR and LF bytes in malformed
+     * encoded words are preserved rather than discarded, and {@code =CR} 
without a following LF is rejected. This lenient handling does not make such
+     * encoded words valid under RFC 2047.
+     * </p>
+     *
      * @param str
      *            quoted-printable string to convert into its original form.
      * @return original string.
diff --git 
a/src/main/java/org/apache/commons/codec/net/QuotedPrintableCodec.java 
b/src/main/java/org/apache/commons/codec/net/QuotedPrintableCodec.java
index 52561e1a..d3ba3478 100644
--- a/src/main/java/org/apache/commons/codec/net/QuotedPrintableCodec.java
+++ b/src/main/java/org/apache/commons/codec/net/QuotedPrintableCodec.java
@@ -45,7 +45,7 @@ import org.apache.commons.codec.binary.StringUtils;
  * Note:
  * </p>
  * <p>
- * Depending on the selected {@code strict} parameter, this class will 
implement a different set of rules of the quoted-printable spec:
+ * Depending on the selected {@code strict} parameter, encoding implements a 
different set of rules of the quoted-printable spec:
  * </p>
  * <ul>
  * <li>{@code strict=false}: only rules #1 and #2 are implemented</li>
@@ -54,6 +54,7 @@ import org.apache.commons.codec.binary.StringUtils;
  * <p>
  * Originally, this class only supported the non-strict mode, but the codec in 
this partial form could already be used for certain applications that do not
  * require quoted-printable line formatting (rules #3, #4, #5), for instance Q 
codec. The strict mode has been added in 1.10.
+ * Decoding is independent of this parameter; see {@link 
#decodeQuotedPrintable(byte[])} for its behavior.
  * </p>
  * <p>
  * This class is immutable and thread-safe.
@@ -99,14 +100,26 @@ public class QuotedPrintableCodec implements 
BinaryEncoder, BinaryDecoder, Strin
     }
 
     /**
-     * Decodes an array quoted-printable characters into an array of original 
bytes. Escaped characters are converted back to their original representation.
+     * Decodes quoted-printable bytes.
+     *
      * <p>
-     * This function fully implements the quoted-printable encoding 
specification (rule #1 through rule #5) as defined in RFC 1521.
+     * Converts hexadecimal escapes to their original bytes, removes soft line 
breaks ({@code =CRLF}), and preserves hard CRLF line breaks.
+     * </p>
+     *
+     * <p>
+     * As a lenient extension for malformed input, unpaired CR and LF bytes 
are also preserved. An equals sign followed by CR without LF is rejected.
+     * This method does not perform full MIME validation: for example, it 
neither removes trailing whitespace nor handles transport padding after an
+     * equals sign. The {@code strict} constructor parameter affects encoding 
only.
+     * </p>
+     *
+     * <p>
+     * Since 1.23.0, unescaped CR and LF bytes are preserved and {@code =CR} 
without a following LF is rejected. Earlier versions discarded unescaped
+     * CR and LF bytes and accepted {@code =CR} as a soft line break.
      * </p>
      *
      * @param bytes array of quoted-printable characters.
-     * @return array of original bytes.
-     * @throws DecoderException Thrown if quoted-printable decoding is 
unsuccessful.
+     * @return array of original bytes, or {@code null} if the input is {@code 
null}.
+     * @throws DecoderException if an escape is incomplete or invalid, 
including a soft line break without the full CRLF pair.
      */
     public static final byte[] decodeQuotedPrintable(final byte[] bytes) 
throws DecoderException {
         if (bytes == null) {
@@ -117,8 +130,12 @@ public class QuotedPrintableCodec implements 
BinaryEncoder, BinaryDecoder, Strin
             final int b = bytes[i];
             if (b == ESCAPE_CHAR) {
                 try {
-                    // if the next octet is a CR we have found a soft line 
break
+                    // rule #5: a soft line break is the escape character 
followed by a CRLF sequence;
+                    // it is removed entirely from the decoded output
                     if (bytes[++i] == CR) {
+                        if (++i >= bytes.length || bytes[i] != LF) {
+                            throw new DecoderException("Invalid 
quoted-printable encoding: soft line break must be =CRLF");
+                        }
                         continue;
                     }
                     final int u = Utils.digit16(bytes[i]);
@@ -127,8 +144,8 @@ public class QuotedPrintableCodec implements BinaryEncoder, 
BinaryDecoder, Strin
                 } catch (final ArrayIndexOutOfBoundsException e) {
                     throw new DecoderException("Invalid quoted-printable 
encoding", e);
                 }
-            } else if (b != CR && b != LF) {
-                // every other octet is appended except for CR & LF
+            } else {
+                // Preserve hard line breaks and, leniently, unpaired CR and 
LF bytes.
                 buffer.write(b);
             }
         }
diff --git a/src/test/java/org/apache/commons/codec/net/QCodecTest.java 
b/src/test/java/org/apache/commons/codec/net/QCodecTest.java
index ea17fade..e4bb7d5f 100644
--- a/src/test/java/org/apache/commons/codec/net/QCodecTest.java
+++ b/src/test/java/org/apache/commons/codec/net/QCodecTest.java
@@ -71,6 +71,22 @@ class QCodecTest {
         assertEquals("ABC?DEF", new QCodec().decode("=?UTF-8?Q?ABC=3FDEF?="));
     }
 
+    @Test
+    void testDecodeMalformedLineBreaks() throws Exception {
+        final QCodec codec = new QCodec();
+        // Exercise both shared decoder paths, with and without underscore 
replacement.
+        for (final String suffix : new String[] { "", "_" }) {
+            final String decodedSuffix = suffix.isEmpty() ? "" : " ";
+            for (final String lineBreak : new String[] { "\r\n", "\r", "\n" }) 
{
+                assertEquals("SEC" + lineBreak + "RET" + decodedSuffix, 
codec.decode("=?UTF-8?Q?SEC" + lineBreak + "RET" + suffix + "?="));
+            }
+            for (final String encoded : new String[] { "foo=\rbar", "foo=\r", 
"foo=\nbar" }) {
+                assertThrows(DecoderException.class, () -> 
codec.decode("=?UTF-8?Q?" + suffix + encoded + "?="));
+            }
+            assertEquals("SEC\r\nRET" + decodedSuffix, 
codec.decode("=?UTF-8?Q?SEC=0D=0ARET" + suffix + "?="));
+        }
+    }
+
     @Test
     void testDecodeObjects() throws Exception {
         final QCodec qcodec = new QCodec();
diff --git 
a/src/test/java/org/apache/commons/codec/net/QuotedPrintableCodecTest.java 
b/src/test/java/org/apache/commons/codec/net/QuotedPrintableCodecTest.java
index b6ec30af..e7ace4a3 100644
--- a/src/test/java/org/apache/commons/codec/net/QuotedPrintableCodecTest.java
+++ b/src/test/java/org/apache/commons/codec/net/QuotedPrintableCodecTest.java
@@ -17,16 +17,23 @@
 
 package org.apache.commons.codec.net;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.nio.charset.StandardCharsets;
 import java.nio.charset.UnsupportedCharsetException;
+import java.util.stream.Stream;
 
 import org.apache.commons.codec.CharEncoding;
 import org.apache.commons.codec.DecoderException;
 import org.apache.commons.codec.EncoderException;
+import org.apache.commons.codec.binary.StringUtils;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.junit.jupiter.api.Test;
 
 /**
@@ -38,6 +45,18 @@ class QuotedPrintableCodecTest {
 
     static final int[] RUSSIAN_STUFF_UNICODE = { 0x412, 0x441, 0x435, 0x43C, 
0x5F, 0x43F, 0x440, 0x438, 0x432, 0x435, 0x442 };
 
+    private static Stream<Arguments> softLineBreakBoundaryCases() {
+        // @formatter:off
+        return Stream.of(
+                Arguments.of("=\r\n", ""),
+                Arguments.of("=\r\nA=\r\n", "A"),
+                Arguments.of("A=\r\n=\r\nB", "AB"),
+                Arguments.of("A=\r\n\r\nB", "A\r\nB"),
+                Arguments.of("A\r\n=\r\nB", "A\r\nB"),
+                Arguments.of("A=0D=0AB", "A\r\nB"));
+        // @formatter:on
+    }
+
     private String constructString(final int[] unicodeChars) {
         final StringBuilder buffer = new StringBuilder();
         if (unicodeChars != null) {
@@ -167,24 +186,34 @@ class QuotedPrintableCodecTest {
         assertEquals(expected, new QuotedPrintableCodec(true).encode(plain));
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = { "Line one\r\nLine two", "SEC\nRET", "SEC\rRET", 
"\r\n", "\r", "\n", "\r\nA\r\n\r\n" })
+    void testHardLineBreakDecode(final String input) throws Exception {
+        final byte[] bytes = StringUtils.getBytesUsAscii(input);
+        assertArrayEquals(bytes, 
QuotedPrintableCodec.decodeQuotedPrintable(bytes));
+        for (final boolean strict : new boolean[] { false, true }) {
+            assertEquals(input, new 
QuotedPrintableCodec(strict).decode(input));
+        }
+    }
+
     @Test
     void testInvalidEncoding() {
         assertThrows(UnsupportedCharsetException.class, () -> new 
QuotedPrintableCodec("NONSENSE"));
     }
 
-    @Test
-    void testSafeCharEncodeDecode() throws Exception {
-        final QuotedPrintableCodec qpcodec = new QuotedPrintableCodec();
-        final String plain = "abc123_-.*~!@#$%^&()+{}\"\\;:`,/[]";
-        final String encoded = qpcodec.encode(plain);
-        assertEquals(plain, encoded, "Safe chars quoted-printable encoding 
test");
-        assertEquals(plain, qpcodec.decode(encoded), "Safe chars 
quoted-printable decoding test");
+    @ParameterizedTest
+    @ValueSource(strings = { "foo=\rbar", "foo=\r", "foo=\nbar", "=\r", "=\n", 
"=\r\r\n" })
+    void testInvalidSoftLineBreakDecode(final String input) {
+        assertThrows(DecoderException.class, () -> 
QuotedPrintableCodec.decodeQuotedPrintable(StringUtils.getBytesUsAscii(input)));
+        for (final boolean strict : new boolean[] { false, true }) {
+            assertThrows(DecoderException.class, () -> new 
QuotedPrintableCodec(strict).decode(input));
+        }
     }
 
     @Test
-    void testSkipNotEncodedCRLF() throws Exception {
-        final String qpdata = "CRLF in an\n encoded text should 
be=20=\r\n\rskipped in the\r decoding.";
-        final String expected = "CRLF in an encoded text should be skipped in 
the decoding.";
+    void testPreserveNotEncodedCRLF() throws Exception {
+        final String qpdata = "CRLF in an\n encoded text should 
be=20=\r\n\rpreserved in the\r decoding.";
+        final String expected = "CRLF in an\n encoded text should be 
\rpreserved in the\r decoding.";
 
         final QuotedPrintableCodec qpcodec = new QuotedPrintableCodec(true);
         assertEquals(expected, qpcodec.decode(qpdata));
@@ -193,6 +222,24 @@ class QuotedPrintableCodecTest {
         assertEquals(expected, qpcodec.decode(encoded));
     }
 
+    @Test
+    void testSafeCharEncodeDecode() throws Exception {
+        final QuotedPrintableCodec qpcodec = new QuotedPrintableCodec();
+        final String plain = "abc123_-.*~!@#$%^&()+{}\"\\;:`,/[]";
+        final String encoded = qpcodec.encode(plain);
+        assertEquals(plain, encoded, "Safe chars quoted-printable encoding 
test");
+        assertEquals(plain, qpcodec.decode(encoded), "Safe chars 
quoted-printable decoding test");
+    }
+
+    @ParameterizedTest
+    @MethodSource("softLineBreakBoundaryCases")
+    void testSoftLineBreakBoundaries(final String input, final String 
expected) throws Exception {
+        assertArrayEquals(StringUtils.getBytesUsAscii(expected), 
QuotedPrintableCodec.decodeQuotedPrintable(StringUtils.getBytesUsAscii(input)));
+        for (final boolean strict : new boolean[] { false, true }) {
+            assertEquals(expected, new 
QuotedPrintableCodec(strict).decode(input));
+        }
+    }
+
     @Test
     void testSoftLineBreakDecode() throws Exception {
         final String qpdata = "If you believe that truth=3Dbeauty, then 
surely=20=\r\nmathematics is the most beautiful branch of philosophy.";

Reply via email to