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");
+ }
+}