This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 18bf2dcb2b79 CAMEL-25133: camel-http-common - set the Content-Length
of a non-chunked response as a long (#27397)
18bf2dcb2b79 is described below
commit 18bf2dcb2b797d43142dda6f1a85d210139c38b3
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 6 14:51:55 2026 +0530
CAMEL-25133: camel-http-common - set the Content-Length of a non-chunked
response as a long (#27397)
* CAMEL-25133: camel-http-common - set the Content-Length of a non-chunked
response as a long
DefaultHttpBinding.doWriteDirectResponse sized a non-chunked response
with the int returned by copyStream, which IOHelper.copy clamps to
Integer.MAX_VALUE since CAMEL-25023 (it wrapped to a negative value
before), so a response of 2 GiB or more got a wrong Content-Length.
The length is now taken as a long from the StreamCache of the
CachedOutputStream the body was copied into and set with
setContentLengthLong. The spooled body is copied to the response from
that same cache, with the response buffer size instead of the length.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CAMEL-25133: camel-http-common - Note that the content length assumes a
stream or 8-bit spool cipher
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
components/camel-http-common/pom.xml | 6 +
.../camel/http/common/DefaultHttpBinding.java | 15 +-
.../DefaultHttpBindingContentLengthTest.java | 217 +++++++++++++++++++++
3 files changed, 233 insertions(+), 5 deletions(-)
diff --git a/components/camel-http-common/pom.xml
b/components/camel-http-common/pom.xml
index cc4a9cab9063..b2ff7cbc2e19 100644
--- a/components/camel-http-common/pom.xml
+++ b/components/camel-http-common/pom.xml
@@ -69,5 +69,11 @@
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <version>${mockito-version}</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
diff --git
a/components/camel-http-common/src/main/java/org/apache/camel/http/common/DefaultHttpBinding.java
b/components/camel-http-common/src/main/java/org/apache/camel/http/common/DefaultHttpBinding.java
index c54b9a6a22b8..bbf974f1111f 100644
---
a/components/camel-http-common/src/main/java/org/apache/camel/http/common/DefaultHttpBinding.java
+++
b/components/camel-http-common/src/main/java/org/apache/camel/http/common/DefaultHttpBinding.java
@@ -543,9 +543,14 @@ public class DefaultHttpBinding implements HttpBinding {
CachedOutputStream stream = new CachedOutputStream(exchange);
try {
// copy directly from input stream to the cached output
stream to get the content length
- int len = copyStream(is, stream, response.getBufferSize());
- // we need to setup the length if message is not chucked
- response.setContentLength(len);
+ copyStream(is, stream, response.getBufferSize());
+ // we need to setup the length if message is not chucked.
Take it as a long from the cache, as the
+ // int count of copyStream cannot hold the length of a
response of 2 GiB or more. This assumes
+ // that a spoolCipher is a stream or 8-bit cipher (as
documented), as the length of a spool file
+ // written with a padded block cipher is larger than the
body
+ StreamCache cache = stream.newStreamCache();
+ long len = cache.length();
+ response.setContentLengthLong(len);
OutputStream current = stream.getCurrentStream();
if (current instanceof ByteArrayOutputStream bos) {
if (LOG.isDebugEnabled()) {
@@ -555,9 +560,9 @@ public class DefaultHttpBinding implements HttpBinding {
} else {
if (LOG.isDebugEnabled()) {
LOG.debug("Streaming response in non-chunked mode
with content-length {} and buffer size: {}", len,
- len);
+ response.getBufferSize());
}
- copyStream(stream.getInputStream(), os, len);
+ copyStream((InputStream) cache, os,
response.getBufferSize());
}
} finally {
IOHelper.close(is, os);
diff --git
a/components/camel-http-common/src/test/java/org/apache/camel/http/common/DefaultHttpBindingContentLengthTest.java
b/components/camel-http-common/src/test/java/org/apache/camel/http/common/DefaultHttpBindingContentLengthTest.java
new file mode 100644
index 000000000000..c0095149643a
--- /dev/null
+++
b/components/camel-http-common/src/test/java/org/apache/camel/http/common/DefaultHttpBindingContentLengthTest.java
@@ -0,0 +1,217 @@
+/*
+ * 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.http.common;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.StreamCache;
+import org.apache.camel.converter.stream.CachedOutputStream;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.spi.StreamCachingStrategy;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.MockedConstruction;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * A non-chunked response is copied into a {@link CachedOutputStream} to learn
its length, which must be set as a long:
+ * the int byte count of the copy cannot hold the length of a response of 2
GiB or more.
+ */
+class DefaultHttpBindingContentLengthTest {
+
+ private static final long THREE_GIGABYTES = 3L * 1024 * 1024 * 1024;
+
+ @TempDir
+ Path spoolDirectory;
+
+ private DefaultCamelContext context;
+ private final ByteArrayOutputStream written = new ByteArrayOutputStream();
+
+ @AfterEach
+ void tearDown() {
+ if (context != null) {
+ context.stop();
+ }
+ }
+
+ private Exchange exchange(boolean spool, byte[] body) {
+ context = new DefaultCamelContext();
+ if (spool) {
+ StreamCachingStrategy strategy =
context.getStreamCachingStrategy();
+ strategy.setSpoolEnabled(true);
+ strategy.setSpoolThreshold(1);
+ strategy.setSpoolDirectory(spoolDirectory.toFile());
+ }
+ context.start();
+
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getMessage().setBody(body);
+ exchange.getMessage().setHeader(Exchange.CONTENT_TYPE,
"application/octet-stream");
+ exchange.getMessage().setHeader(Exchange.HTTP_CHUNKED, false);
+ return exchange;
+ }
+
+ private HttpServletResponse response() throws IOException {
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ when(response.getBufferSize()).thenReturn(8192);
+ when(response.getOutputStream()).thenReturn(new
CapturingServletOutputStream(written));
+ return response;
+ }
+
+ private static byte[] body() {
+ return "a response body that is not
chunked".getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * The length of a response of 3 GiB, without writing 3 GiB anywhere: the
{@link CachedOutputStream} the body is
+ * spooled into reports that length, and only a few bytes are actually
sent.
+ */
+ @Test
+ void aResponseOfTwoGigabytesOrMoreGetsItsLengthAsALong() throws Exception {
+ byte[] body = body();
+ Exchange exchange = exchange(true, body);
+ HttpServletResponse response = response();
+
+ SpooledCache spooled = new SpooledCache(body, THREE_GIGABYTES);
+
+ try (MockedConstruction<CachedOutputStream> spool =
mockConstruction(CachedOutputStream.class, (stream, ctx) -> {
+ // a spooled cache: the current stream is a file, not a byte
array, and the body is read back from the cache
+
when(stream.getCurrentStream()).thenReturn(OutputStream.nullOutputStream());
+ when(stream.newStreamCache()).thenReturn(spooled);
+ when(stream.getInputStream()).thenReturn(spooled);
+ })) {
+ new
DefaultHttpBinding().doWriteDirectResponse(exchange.getMessage(), response,
exchange);
+ }
+
+ verify(response).setContentLengthLong(THREE_GIGABYTES);
+ verify(response, never()).setContentLength(anyInt());
+ assertArrayEquals(body, written.toByteArray());
+ }
+
+ @Test
+ void aSpooledResponseGetsItsLength() throws Exception {
+ byte[] body = body();
+ Exchange exchange = exchange(true, body);
+ HttpServletResponse response = response();
+
+ new DefaultHttpBinding().doWriteDirectResponse(exchange.getMessage(),
response, exchange);
+
+ try (Stream<Path> files = Files.walk(spoolDirectory)) {
+ assertTrue(files.anyMatch(Files::isRegularFile), "the body should
have been spooled to a file");
+ }
+ verify(response).setContentLengthLong(body.length);
+ verify(response, never()).setContentLength(anyInt());
+ assertArrayEquals(body, written.toByteArray());
+ }
+
+ @Test
+ void anInMemoryResponseGetsItsLength() throws Exception {
+ byte[] body = body();
+ Exchange exchange = exchange(false, body);
+ HttpServletResponse response = response();
+
+ new DefaultHttpBinding().doWriteDirectResponse(exchange.getMessage(),
response, exchange);
+
+ verify(response).setContentLengthLong(body.length);
+ verify(response, never()).setContentLength(anyInt());
+ assertArrayEquals(body, written.toByteArray());
+ }
+
+ /**
+ * The cache of a spooled body that reports a length larger than the bytes
it holds.
+ */
+ private static final class SpooledCache extends ByteArrayInputStream
implements StreamCache {
+
+ private final long length;
+
+ private SpooledCache(byte[] data, long length) {
+ super(data);
+ this.length = length;
+ }
+
+ @Override
+ public void writeTo(OutputStream os) throws IOException {
+ os.write(buf, 0, count);
+ }
+
+ @Override
+ public StreamCache copy(Exchange exchange) {
+ return new SpooledCache(buf, length);
+ }
+
+ @Override
+ public boolean inMemory() {
+ return false;
+ }
+
+ @Override
+ public long length() {
+ return length;
+ }
+
+ @Override
+ public long position() {
+ return pos;
+ }
+ }
+
+ private static final class CapturingServletOutputStream extends
ServletOutputStream {
+
+ private final ByteArrayOutputStream target;
+
+ private CapturingServletOutputStream(ByteArrayOutputStream target) {
+ this.target = target;
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setWriteListener(WriteListener writeListener) {
+ // not used
+ }
+
+ @Override
+ public void write(int b) {
+ target.write(b);
+ }
+ }
+}