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

chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-mime4j.git

commit 4a0f8218c61fdde09316a4c9f97c10630fd7b50e
Author: Benoit TELLIER <[email protected]>
AuthorDate: Wed Nov 4 15:08:55 2026 +0100

    [FIX] QuotedPrintable: keep lone CR and LF through a decode / encode round 
trip
    
    On a malformed escape such as "=2" CRLF, QuotedPrintableInputStream wrote
    the byte after the escape as data even when it was a CR, so the CRLF became
    "\r\r\n". The text-mode QuotedPrintableOutputStream then lost a CR followed
    by another CR, a pending CR after a pending blank, and wrote any other lone
    CR or LF unencoded, which the decoder turns into CRLF (or rejects in strict
    mode). DefaultMessageWriter therefore changed such bodies. The decoder now
    leaves only "=" and the first byte as is and handles the third one as usual;
    the encoder keeps only CRLF as a line break and escapes any other CR or LF
    (=0D, =0A), as RFC 2045 6.7 rule (4) requires.
    
    Fixes #109
---
 .../mime4j/codec/QuotedPrintableInputStream.java   |  5 +++-
 .../mime4j/codec/QuotedPrintableOutputStream.java  | 12 ++++++---
 .../codec/QuotedPrintableInputStreamTest.java      |  7 +++++
 .../codec/QuotedPrintableTextEncodeTest.java       | 17 ++++++++++--
 .../resources/mimetools-testmsgs/german-qp.out     | 24 +++++------------
 .../mime4j/message/DefaultMessageWriterTest.java   | 31 ++++++++++++++++++++++
 6 files changed, 72 insertions(+), 24 deletions(-)

diff --git 
a/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableInputStream.java
 
b/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableInputStream.java
index ee5ce536..f90e0af7 100644
--- 
a/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableInputStream.java
+++ 
b/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableInputStream.java
@@ -282,7 +282,10 @@ public class QuotedPrintableInputStream extends 
InputStream {
                             // TODO see MIME4J-160
                             index = transfer(EQ, buffer, index, to, true);
                             index = transfer(b2, buffer, index, to, false);
-                            index = transfer(b3, buffer, index, to, false);
+                            if (b3 != -1) {
+                                // b3 may be a line break or a blank: give it 
the usual handling
+                                pos--;
+                            }
                         } else {
                             index = transfer((upper << 4) | lower, buffer, 
index, to, true);
                         }
diff --git 
a/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableOutputStream.java
 
b/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableOutputStream.java
index b37f3e3c..5c572a80 100644
--- 
a/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableOutputStream.java
+++ 
b/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableOutputStream.java
@@ -91,8 +91,10 @@ public class QuotedPrintableOutputStream extends 
FilterOutputStream {
             plain(SP);
         } else if (pendingTab) {
             plain(TB);
-        } else if (pendingCR) {
-            plain(CR);
+        }
+        if (pendingCR) {
+            // a CR that is not followed by LF is not a line break
+            escape(CR);
         }
         clearPending();
     }
@@ -120,14 +122,18 @@ public class QuotedPrintableOutputStream extends 
FilterOutputStream {
                     lineBreak();
                     clearPending();
                 } else {
+                    // a LF that is not preceded by CR is not a line break
                     writePending();
-                    plain(next);
+                    escape(next);
                 }
             }
         } else if (next == CR) {
             if (binary)  {
                 escape(next);
             } else {
+                if (pendingCR) {
+                    writePending();
+                }
                 pendingCR = true;
             }
         } else {
diff --git 
a/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableInputStreamTest.java
 
b/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableInputStreamTest.java
index 4ed94a3b..0539396d 100644
--- 
a/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableInputStreamTest.java
+++ 
b/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableInputStreamTest.java
@@ -277,4 +277,11 @@ public class QuotedPrintableInputStreamTest {
             }
         }
     }
+
+    @Test
+    public void malformedEscapeBeforeLineBreakKeepsTheLineBreak() throws 
Exception {
+        InputStream in = new 
QuotedPrintableInputStream(InputStreams.createAscii("a=2\r\nb\r\n"), 
DecodeMonitor.SILENT);
+        // "=2" is left as is (MIME4J-160), the CRLF stays one line break
+        Assert.assertEquals("a=2\r\nb\r\n", readText(in));
+    }
 }
diff --git 
a/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableTextEncodeTest.java
 
b/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableTextEncodeTest.java
index 528dc689..d48cbe27 100644
--- 
a/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableTextEncodeTest.java
+++ 
b/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableTextEncodeTest.java
@@ -116,12 +116,25 @@ public class QuotedPrintableTextEncodeTest {
 
     @Test
     public void testDontEscapeLF() throws Exception {
-        check("Ready\nFor\n", "Ready\nFor\n");
+        check("Ready\nFor\n", "Ready=0AFor=0A");
     }
 
     @Test
     public void testDontEscapeCR() throws Exception {
-        check("Ready\rFor\r", "Ready\rFor\r");
+        check("Ready\rFor\r", "Ready=0DFor=0D");
+    }
+
+    @Test
+    public void testLoneCarriageReturnsAndLineFeedsRoundTrip() throws 
Exception {
+        check("a\r\r\nb\rc", "a=0D\r\nb=0Dc");
+        check("a \rb\t\r", "a =0Db\t=0D");
+        check("a\n\r\n", "a=0A\r\n");
+        byte[] content = ContentUtil.toAsciiByteArray("a\r\r\nb\rc 
\r\t\n\n\r");
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        EncoderUtil.encodeQ(InputStreams.create(content), out);
+        // strict decoding: the encoder must not write a bare CR or LF
+        InputStream in = new 
QuotedPrintableInputStream(InputStreams.create(out.toByteArray()), 
DecodeMonitor.STRICT);
+        assertEquals(content, ContentUtil.buffer(in));
     }
 
     @Test
diff --git a/core/src/test/resources/mimetools-testmsgs/german-qp.out 
b/core/src/test/resources/mimetools-testmsgs/german-qp.out
index 3075aa0e..1b6d3fb3 100644
--- a/core/src/test/resources/mimetools-testmsgs/german-qp.out
+++ b/core/src/test/resources/mimetools-testmsgs/german-qp.out
@@ -9,21 +9,9 @@ Date: Wed, 21 Dec 2005 22:02:44 +0100
 To: [email protected]
 From: =?ISO-8859-15?Q?J=F6rn?= Reder <[email protected]>
 
-
-Hallo,
-
-das ist eine Testnachricht mit 8 Bit S=F6nderz=E4ichen, und obendr=
-ein noch 
-quoted-printable kodiert=2E
-
-Gr=FC=DFe,
-
-J=F6rn
-
--- 
- =2E''`=2E  J=F6rn Reder <joern@zyn=2Ede>
-: :' :  http://www=2Eexit1=
-=2Eorg/ http://www=2Ezyn=2Ede/
-`=2E `'
-  `-    Debian GNU/Linux -- The powe=
-r of freedom
+=0AHallo,=0A=0Adas ist eine Testnachricht mit 8 Bit S=F6nderz=E4ichen, und =
+obendrein noch =0Aquoted-printable kodiert=2E=0A=0AGr=FC=DFe,=0A=0AJ=F6rn=
+=0A
+-- =0A =2E''`=2E  J=F6rn Reder <joern@zyn=2Ede>=0A: :' :  http://www=2Eexi=
+t1=2Eorg/ http://www=2Ezyn=2Ede/=0A`=2E `'=0A  `-    Debian GNU/Linux -- Th=
+e power of freedom=0A
\ No newline at end of file
diff --git 
a/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageWriterTest.java
 
b/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageWriterTest.java
index 64b11aae..6fd79e2d 100644
--- 
a/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageWriterTest.java
+++ 
b/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageWriterTest.java
@@ -24,6 +24,7 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import org.apache.james.mime4j.Charsets;
 import org.apache.james.mime4j.MimeIOException;
+import org.apache.james.mime4j.codec.DecodeMonitor;
 import org.apache.james.mime4j.io.MaxNestingDepthLimitException;
 import org.apache.james.mime4j.stream.MimeConfig;
 import java.io.ByteArrayInputStream;
@@ -35,6 +36,7 @@ import java.util.Collections;
 import org.apache.james.mime4j.dom.Header;
 import org.apache.james.mime4j.dom.Message;
 import org.apache.james.mime4j.dom.Multipart;
+import org.apache.james.mime4j.dom.SingleBody;
 import org.apache.james.mime4j.dom.TextBody;
 import org.apache.james.mime4j.dom.address.Mailbox;
 import org.apache.james.mime4j.dom.field.AddressListField;
@@ -42,6 +44,7 @@ import org.apache.james.mime4j.dom.field.MailboxListField;
 import org.apache.james.mime4j.field.Fields;
 import org.apache.james.mime4j.field.address.LenientAddressParser;
 import org.apache.james.mime4j.stream.RawField;
+import org.apache.james.mime4j.util.ContentUtil;
 import org.junit.Test;
 
 public class DefaultMessageWriterTest {
@@ -303,4 +306,32 @@ public class DefaultMessageWriterTest {
         assertThat(written).contains("Content-Type: text/plain; 
charset=UTF-8\r\n");
         assertThat(written).endsWith("body é");
     }
+
+    @Test
+    public void quotedPrintableBodyWithMalformedEscapeSurvivesWriteAndParse() 
throws Exception {
+        byte[] raw = "Content-Transfer-Encoding: 
quoted-printable\r\n\r\na=2\r\nb\r\n".getBytes(StandardCharsets.US_ASCII);
+        DefaultMessageBuilder builder = new DefaultMessageBuilder();
+        Message m1 = builder.parseMessage(new ByteArrayInputStream(raw));
+        ByteArrayOutputStream w1 = new ByteArrayOutputStream();
+        new DefaultMessageWriter().writeMessage(m1, w1);
+        Message m2 = builder.parseMessage(new 
ByteArrayInputStream(w1.toByteArray()));
+
+        assertThat(ContentUtil.buffer(((SingleBody) 
m2.getBody()).getInputStream()))
+            .isEqualTo(ContentUtil.buffer(((SingleBody) 
m1.getBody()).getInputStream()));
+    }
+
+    @Test
+    public void 
quotedPrintableTextWithLoneCarriageReturnSurvivesStrictWriteAndParse() throws 
Exception {
+        byte[] raw = "Content-Transfer-Encoding: 
quoted-printable\r\n\r\na=0D\r\nb=0Dc=0A\r\n".getBytes(StandardCharsets.US_ASCII);
+        DefaultMessageBuilder builder = new DefaultMessageBuilder();
+        builder.setMimeEntityConfig(MimeConfig.STRICT);
+        builder.setDecodeMonitor(DecodeMonitor.STRICT);
+        Message m1 = builder.parseMessage(new ByteArrayInputStream(raw));
+        ByteArrayOutputStream w1 = new ByteArrayOutputStream();
+        new DefaultMessageWriter().writeMessage(m1, w1);
+        Message m2 = builder.parseMessage(new 
ByteArrayInputStream(w1.toByteArray()));
+
+        assertThat(ContentUtil.buffer(((SingleBody) 
m2.getBody()).getInputStream()))
+            
.isEqualTo("a\r\r\nb\rc\n\r\n".getBytes(StandardCharsets.US_ASCII));
+    }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to