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

davsclaus pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/camel-4.22.x by this push:
     new 342f73999fed [backport camel-4.22.x] CAMEL-26306: camel-as2 - keep the 
CR of a CRLF that is split between two reads of the line reader (#28772)
342f73999fed is described below

commit 342f73999fed4bef3e1fbc0da8c885a67f75ba6b
Author: Guillaume Nodet <[email protected]>
AuthorDate: Sat Oct 31 20:03:55 2026 +0100

    [backport camel-4.22.x] CAMEL-26306: camel-as2 - keep the CR of a CRLF that 
is split between two reads of the line reader (#28772)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../as2/api/io/AS2SessionInputBuffer.java          |   6 +-
 .../entity/EntityParserContentLineEndingTest.java  |  91 ++++++++++++++++
 .../as2/api/io/AS2SessionInputBufferTest.java      | 120 +++++++++++++++++++++
 3 files changed, 216 insertions(+), 1 deletion(-)

diff --git 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/io/AS2SessionInputBuffer.java
 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/io/AS2SessionInputBuffer.java
index 5463898201d2..589d95441fd1 100644
--- 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/io/AS2SessionInputBuffer.java
+++ 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/io/AS2SessionInputBuffer.java
@@ -186,7 +186,11 @@ public class AS2SessionInputBuffer implements 
SessionInputBuffer {
                 if (this.buffer[i] == LF) {
                     pos = i;
                     this.lastLineReadTerminatedByLineFeed = true;
-                    if (i > 0 && this.buffer[i - 1] == CR) {
+                    // the CR before the LF is either in the read buffer, or 
it was the last byte of the previous read
+                    // and is already in the line buffer (the line ending is 
split across two reads)
+                    if (i > this.bufferpos
+                            ? this.buffer[i - 1] == CR
+                            : !this.linebuffer.isEmpty() && 
this.linebuffer.byteAt(this.linebuffer.length() - 1) == CR) {
                         this.lastLineReadEnrichedByCarriageReturn = true;
                     }
                     break;
diff --git 
a/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/entity/EntityParserContentLineEndingTest.java
 
b/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/entity/EntityParserContentLineEndingTest.java
index 60eb516edcd2..2094f720ac26 100644
--- 
a/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/entity/EntityParserContentLineEndingTest.java
+++ 
b/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/entity/EntityParserContentLineEndingTest.java
@@ -21,20 +21,28 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
 import java.security.*;
+import java.security.cert.Certificate;
 import java.security.cert.X509Certificate;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
 
 import org.apache.camel.component.as2.api.AS2Header;
+import org.apache.camel.component.as2.api.AS2MediaType;
+import org.apache.camel.component.as2.api.AS2SignatureAlgorithm;
+import org.apache.camel.component.as2.api.AS2SignedDataGenerator;
+import org.apache.camel.component.as2.api.AS2TransferEncoding;
 import org.apache.camel.component.as2.api.io.AS2SessionInputBuffer;
 import org.apache.camel.component.as2.api.util.EntityUtils;
 import org.apache.camel.component.as2.api.util.HttpMessageUtils;
+import org.apache.camel.component.as2.api.util.SigningUtils;
 import org.apache.hc.core5.http.*;
 import org.apache.hc.core5.http.impl.BasicHttpTransportMetrics;
 import org.apache.hc.core5.http.impl.EnglishReasonPhraseCatalog;
 import org.apache.hc.core5.http.io.entity.BasicHttpEntity;
+import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
 import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
 import org.apache.hc.core5.http.message.BasicHeader;
 import org.bouncycastle.asn1.x500.X500Name;
@@ -307,6 +315,89 @@ public class EntityParserContentLineEndingTest {
         return utils.createAuthorityKeyIdentifier(info);
     }
 
+    @Test
+    void parseEdiMessageKeepsACrLfSplitByTheReadBuffer() throws Exception {
+        // the first line is 8191 bytes, so its CR is the last byte of the 
first 8 KB read of the entity content and
+        // its LF the first byte of the second read
+        StringBuilder sb = new StringBuilder();
+        sb.append("A".repeat(DEFAULT_BUFFER_SIZE - 1)).append("\r\n");
+        assertEquals(DEFAULT_BUFFER_SIZE - 1, sb.indexOf("\r"));
+        for (int i = 0; i < 100; i++) {
+            sb.append("SEG+").append(i).append("'\r\n");
+        }
+        String edi = sb.toString();
+        byte[] content = edi.getBytes(StandardCharsets.US_ASCII);
+        BasicClassicHttpRequest request = new BasicClassicHttpRequest("POST", 
"/");
+        request.addHeader(AS2Header.CONTENT_TYPE, 
AS2MediaType.APPLICATION_EDIFACT);
+        request.setEntity(new BasicHttpEntity(
+                new ByteArrayInputStream(content), content.length, 
ContentType.parse(AS2MediaType.APPLICATION_EDIFACT)));
+
+        EntityParser.parseAS2MessageEntity(request);
+
+        ApplicationEntity ediEntity = (ApplicationEntity) request.getEntity();
+        assertEquals(edi, ediEntity.getEdiMessage(), "Unexpected EDI message");
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    void 
signedEdiMessageWithACrLfSplitByTheReadBufferKeepsItsSignature(boolean 
crLfBeforeDelimiter) throws Exception {
+        Security.addProvider(new BouncyCastleProvider());
+        KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", "BC");
+        kpg.initialize(2048, new SecureRandom());
+        KeyPair signingKP = kpg.generateKeyPair();
+        String signingDN = "O=AS2 Split Line Ending Test, C=US";
+        Certificate[] chain = { makeCertificate(signingKP, signingDN, 
signingKP, signingDN) };
+
+        // the EDI content starts after the start boundary and the part 
headers of the multipart/signed body
+        byte[] probe = signedBody(signedEntity("X", chain, 
signingKP.getPrivate()));
+        int contentOffset = new String(probe, 
StandardCharsets.US_ASCII).indexOf("\r\n\r\n") + 4;
+        String edi;
+        if (crLfBeforeDelimiter) {
+            // the content ends at offset 8191: the CR of the CRLF before the 
next delimiter line is the last byte of
+            // the first 8 KB read of the body
+            edi = "A".repeat(DEFAULT_BUFFER_SIZE - 1 - contentOffset);
+        } else {
+            // the CR of the first line of the content is the last byte of the 
first 8 KB read of the body
+            StringBuilder sb = new 
StringBuilder("A".repeat(DEFAULT_BUFFER_SIZE - 1 - 
contentOffset)).append("\r\n");
+            for (int i = 0; i < 100; i++) {
+                sb.append("SEG+").append(i).append("'\r\n");
+            }
+            edi = sb.toString();
+        }
+        MultipartSignedEntity signedEntity = signedEntity(edi, chain, 
signingKP.getPrivate());
+        byte[] body = signedBody(signedEntity);
+        assertEquals('\r', body[DEFAULT_BUFFER_SIZE - 1]);
+        assertEquals('\n', body[DEFAULT_BUFFER_SIZE]);
+
+        BasicClassicHttpRequest request = new BasicClassicHttpRequest("POST", 
"/");
+        request.addHeader(AS2Header.CONTENT_TYPE, 
signedEntity.getContentType());
+        request.setEntity(new BasicHttpEntity(
+                new ByteArrayInputStream(body), body.length, 
ContentType.parse(signedEntity.getContentType())));
+
+        EntityParser.parseAS2MessageEntity(request);
+
+        MultipartSignedEntity received = (MultipartSignedEntity) 
request.getEntity();
+        assertTrue(SigningUtils.isValid(received, chain), "The signature of 
the received message must be valid");
+        assertEquals(edi, ((ApplicationEntity) 
received.getSignedDataEntity()).getEdiMessage(), "Unexpected EDI message");
+    }
+
+    private static MultipartSignedEntity signedEntity(String edi, 
Certificate[] chain, PrivateKey privateKey)
+            throws Exception {
+        ApplicationEntity ediEntity = 
EntityUtils.createEDIEntity(edi.getBytes(StandardCharsets.US_ASCII),
+                ContentType.parse(AS2MediaType.APPLICATION_EDIFACT), null, 
false, null);
+        AS2SignedDataGenerator signer
+                = 
SigningUtils.createSigningGenerator(AS2SignatureAlgorithm.SHA256WITHRSA, chain, 
privateKey);
+        return new MultipartSignedEntity(
+                ediEntity, signer, StandardCharsets.US_ASCII.name(), 
AS2TransferEncoding.BASE64, true,
+                "----=_Part_split_line_ending");
+    }
+
+    private static byte[] signedBody(MultipartSignedEntity signedEntity) 
throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        signedEntity.writeTo(out);
+        return out.toByteArray();
+    }
+
     private DispositionNotificationMultipartReportEntity 
createMdnEntity(String reportContent, String boundary)
             throws Exception {
         InputStream is = new ByteArrayInputStream(
diff --git 
a/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/io/AS2SessionInputBufferTest.java
 
b/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/io/AS2SessionInputBufferTest.java
new file mode 100644
index 000000000000..dcdb5b533f2d
--- /dev/null
+++ 
b/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/io/AS2SessionInputBufferTest.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.as2.api.io;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.hc.core5.http.impl.BasicHttpTransportMetrics;
+import org.apache.hc.core5.util.CharArrayBuffer;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AS2SessionInputBufferTest {
+
+    /**
+     * An input stream that returns at most one of the given chunks per read, 
as a socket stream does.
+     */
+    private static final class ChunkedInputStream extends InputStream {
+        private final byte[][] chunks;
+        private int chunk;
+        private int offset;
+
+        ChunkedInputStream(String... chunks) {
+            this.chunks = new byte[chunks.length][];
+            for (int i = 0; i < chunks.length; i++) {
+                this.chunks[i] = chunks[i].getBytes(StandardCharsets.US_ASCII);
+            }
+        }
+
+        @Override
+        public int read() throws IOException {
+            byte[] one = new byte[1];
+            return read(one, 0, 1) == -1 ? -1 : one[0] & 0xff;
+        }
+
+        @Override
+        public int read(byte[] b, int off, int len) {
+            while (chunk < chunks.length && offset == chunks[chunk].length) {
+                chunk++;
+                offset = 0;
+            }
+            if (chunk == chunks.length) {
+                return -1;
+            }
+            int n = Math.min(len, chunks[chunk].length - offset);
+            System.arraycopy(chunks[chunk], offset, b, off, n);
+            offset += n;
+            return n;
+        }
+    }
+
+    private static String readLine(AS2SessionInputBuffer inbuffer, InputStream 
is) throws IOException {
+        CharArrayBuffer line = new CharArrayBuffer(16);
+        assertTrue(inbuffer.readLine(line, is) >= 0, "Expected a line");
+        return line.toString();
+    }
+
+    @Test
+    void readLineSeesTheCarriageReturnOfALineEndingSplitAcrossTwoReads() 
throws Exception {
+        AS2SessionInputBuffer inbuffer = new AS2SessionInputBuffer(new 
BasicHttpTransportMetrics(), 64);
+        InputStream is = new ChunkedInputStream("abc\r", "\ndef\r\n");
+
+        assertEquals("abc", readLine(inbuffer, is));
+        assertTrue(inbuffer.isLastLineReadTerminatedByLineFeed(), "Line 
terminated by LF");
+        assertTrue(inbuffer.isLastLineReadEnrichedByCarriageReturn(), "Line 
terminated by CRLF");
+
+        assertEquals("def", readLine(inbuffer, is));
+        assertTrue(inbuffer.isLastLineReadTerminatedByLineFeed(), "Line 
terminated by LF");
+        assertTrue(inbuffer.isLastLineReadEnrichedByCarriageReturn(), "Line 
terminated by CRLF");
+    }
+
+    @Test
+    void readLineSeesTheCarriageReturnAtTheEndOfAFullReadBuffer() throws 
Exception {
+        // the CR is the last byte of the first fill of the 8 byte read buffer
+        AS2SessionInputBuffer inbuffer = new AS2SessionInputBuffer(new 
BasicHttpTransportMetrics(), 8);
+        InputStream is = new 
ByteArrayInputStream("1234567\r\nx\n".getBytes(StandardCharsets.US_ASCII));
+
+        assertEquals("1234567", readLine(inbuffer, is));
+        assertTrue(inbuffer.isLastLineReadTerminatedByLineFeed(), "Line 
terminated by LF");
+        assertTrue(inbuffer.isLastLineReadEnrichedByCarriageReturn(), "Line 
terminated by CRLF");
+
+        assertEquals("x", readLine(inbuffer, is));
+        assertTrue(inbuffer.isLastLineReadTerminatedByLineFeed(), "Line 
terminated by LF");
+        assertFalse(inbuffer.isLastLineReadEnrichedByCarriageReturn(), "Line 
terminated by a bare LF");
+    }
+
+    @Test
+    void readLineDoesNotTakeACarriageReturnBeforeTheLineForItsLineEnding() 
throws Exception {
+        // the CR is read by read(InputStream); the line read next is empty 
and ends with a bare LF
+        AS2SessionInputBuffer inbuffer = new AS2SessionInputBuffer(new 
BasicHttpTransportMetrics(), 64);
+        InputStream is = new 
ByteArrayInputStream("\r\nq\r\n".getBytes(StandardCharsets.US_ASCII));
+
+        assertEquals('\r', inbuffer.read(is));
+        assertEquals("", readLine(inbuffer, is));
+        assertTrue(inbuffer.isLastLineReadTerminatedByLineFeed(), "Line 
terminated by LF");
+        assertFalse(inbuffer.isLastLineReadEnrichedByCarriageReturn(), "Line 
terminated by a bare LF");
+
+        assertEquals("q", readLine(inbuffer, is));
+        assertTrue(inbuffer.isLastLineReadEnrichedByCarriageReturn(), "Line 
terminated by CRLF");
+    }
+}

Reply via email to