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 0db23e8c105b CAMEL-26047: camel-minio - upload a body of known length
with its own length, not a stale CamelMinioContentLength (#28540)
0db23e8c105b is described below
commit 0db23e8c105b9da8358272e3a0377e8fed1140a2
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 27 15:42:23 2026 +0530
CAMEL-26047: camel-minio - upload a body of known length with its own
length, not a stale CamelMinioContentLength (#28540)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../camel/component/minio/MinioProducer.java | 44 ++++---
.../apache/camel/component/minio/FakeS3Server.java | 40 +++++-
.../minio/MinioProducerStaleContentLengthTest.java | 139 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 8 ++
4 files changed, 212 insertions(+), 19 deletions(-)
diff --git
a/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioProducer.java
b/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioProducer.java
index 694d73cd7f24..13dbf48c6eb9 100644
---
a/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioProducer.java
+++
b/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioProducer.java
@@ -152,27 +152,20 @@ public class MinioProducer extends DefaultProducer {
if (object instanceof File file) {
filePayload = file;
inputStream = new FileInputStream(filePayload);
- if (contentLength <= 0) {
- contentLength = filePayload.length();
- }
+ contentLength = bodyLength(filePayload, inputStream,
contentLength);
} else {
- if (contentLength <= 0) {
- // such as a java.nio.file.Path, byte[] or stream cache
- contentLength = PayloadHelper.getLength(object);
- }
inputStream =
exchange.getMessage().getMandatoryBody(InputStream.class);
- if (contentLength <= 0) {
+ contentLength = bodyLength(object, inputStream,
contentLength);
+ if (contentLength == -1) {
+ // fallback to copy the data to calculate the length,
which uses stream caching
+ // so big payloads are spooled to disk when spooling
is enabled
+ LOG.debug("The content length is not defined. It needs
to be determined by copying the data");
+ inputStream = PayloadHelper.cacheStream(exchange,
inputStream);
contentLength = PayloadHelper.getLength(inputStream);
- if (contentLength == -1) {
- // fallback to copy the data to calculate the
length, which uses stream caching
- // so big payloads are spooled to disk when
spooling is enabled
- LOG.debug("The content length is not defined. It
needs to be determined by copying the data");
- inputStream = PayloadHelper.cacheStream(exchange,
inputStream);
- contentLength =
PayloadHelper.getLength(inputStream);
- }
}
}
- if (contentLength > 0) {
+ if (contentLength >= 0) {
+ // also an empty body, so a stale content-length header is
not stored as metadata
objectMetadata.put(Exchange.CONTENT_LENGTH,
String.valueOf(contentLength));
}
doPutObject(exchange, bucketName, objectName, objectMetadata,
extraHeaders, inputStream, contentLength);
@@ -186,6 +179,25 @@ public class MinioProducer extends DefaultProducer {
}
}
+ /**
+ * The length of the body when it is known without reading it: the body is
a file, java.nio.file.Path, byte[] or
+ * stream cache, or its input stream is backed by a byte array or a file
(a String body is converted to a
+ * ByteArrayInputStream). Otherwise the given content length (the
content-length header, or the length of a
+ * WrappedFile) when it is positive, else -1. The header is not used for a
body of known length, as it can be stale
+ * once the route changed the body (for example the length of an object
read by the consumer), and the client sends
+ * exactly that many bytes.
+ */
+ private static long bodyLength(Object body, InputStream inputStream, long
contentLength) {
+ long length = PayloadHelper.getLength(body);
+ if (length < 0) {
+ length = PayloadHelper.getLength(inputStream);
+ }
+ if (length >= 0) {
+ return length;
+ }
+ return contentLength > 0 ? contentLength : -1;
+ }
+
private void doPutObject(
Exchange exchange, String bucketName, String objectName,
Map<String, String> objectMetadata,
Map<String, String> extraHeaders, InputStream inputStream, long
contentLength)
diff --git
a/components/camel-minio/src/test/java/org/apache/camel/component/minio/FakeS3Server.java
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/FakeS3Server.java
index 6c0523ec439d..2cc55c17c5e7 100644
---
a/components/camel-minio/src/test/java/org/apache/camel/component/minio/FakeS3Server.java
+++
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/FakeS3Server.java
@@ -17,12 +17,14 @@
package org.apache.camel.component.minio;
import java.io.IOException;
+import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
+import java.util.Locale;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Set;
@@ -34,9 +36,9 @@ import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
/**
- * A minimal in-process fake of the S3 API calls the minio consumer makes for
a single bucket: head bucket, list objects
- * (v2, paginated with max-keys and start-after/continuation-token), stat, get
(also of a byte range) and delete object.
- * The gets (or the stats and gets) of an object can be refused with 403
(AccessDenied).
+ * A minimal in-process fake of the S3 API calls the minio consumer and
producer make for a single bucket: head bucket,
+ * list objects (v2, paginated with max-keys and
start-after/continuation-token), stat, get (also of a byte range), put
+ * and delete object. The gets (or the stats and gets) of an object can be
refused with 403 (AccessDenied).
*/
final class FakeS3Server {
@@ -44,6 +46,7 @@ final class FakeS3Server {
private final HttpServer server;
private final NavigableMap<String, byte[]> objects = new
ConcurrentSkipListMap<>();
private final Map<String, AtomicInteger> gets = new ConcurrentHashMap<>();
+ private final Map<String, Map<String, String>> userMetadata = new
ConcurrentHashMap<>();
private final Set<String> deleteWhenListed = ConcurrentHashMap.newKeySet();
private final Set<String> deleteWhenStatted =
ConcurrentHashMap.newKeySet();
private final Set<String> refusedGets = ConcurrentHashMap.newKeySet();
@@ -104,6 +107,20 @@ final class FakeS3Server {
return objects.containsKey(key);
}
+ /**
+ * The content of the object as it was stored, or null.
+ */
+ byte[] content(String key) {
+ return objects.get(key);
+ }
+
+ /**
+ * The user metadata (x-amz-meta-* request headers, names in lower case)
of the last put of the object, or null.
+ */
+ Map<String, String> userMetadata(String key) {
+ return userMetadata.get(key);
+ }
+
int gets(String key) {
AtomicInteger n = gets.get(key);
return n != null ? n.get() : 0;
@@ -162,6 +179,23 @@ final class FakeS3Server {
}
}
}
+ case "PUT" -> {
+ // a single part upload: the body is the content of the object
(the client sends the length it was
+ // given as Content-Length)
+ try (InputStream is = http.getRequestBody()) {
+ objects.put(key, is.readAllBytes());
+ }
+ Map<String, String> meta = new HashMap<>();
+ http.getRequestHeaders().forEach((name, values) -> {
+ if
(name.toLowerCase(Locale.ROOT).startsWith("x-amz-meta-")) {
+ meta.put(name.toLowerCase(Locale.ROOT), values.get(0));
+ }
+ });
+ userMetadata.put(key, meta);
+ http.getResponseHeaders().add("ETag",
"\"0123456789abcdef0123456789abcdef\"");
+ http.sendResponseHeaders(200, -1);
+ http.close();
+ }
case "DELETE" -> {
objects.remove(key);
http.sendResponseHeaders(204, -1);
diff --git
a/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioProducerStaleContentLengthTest.java
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioProducerStaleContentLengthTest.java
new file mode 100644
index 000000000000..6f976545ccdb
--- /dev/null
+++
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioProducerStaleContentLengthTest.java
@@ -0,0 +1,139 @@
+/*
+ * 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.minio;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FilterInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * The minio consumer sets CamelMinioContentLength to the size of the object
it read. When a route changes the body and
+ * stores it with a minio producer, that header is stale: the client sends
exactly the given number of bytes (minio
+ * PartReader), so a longer body was cut and a shorter one failed. The real
MinioClient runs against an in-process fake
+ * S3 server that stores what it receives.
+ */
+class MinioProducerStaleContentLengthTest extends CamelTestSupport {
+
+ @TempDir
+ Path dir;
+
+ private FakeS3Server s3;
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ s3 = new FakeS3Server("bkt");
+ s3.start();
+ return super.createCamelContext();
+ }
+
+ @AfterEach
+ void stopServer() {
+ if (s3 != null) {
+ s3.stop();
+ }
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:put")
+
.to("minio://bkt?accessKey=xxx&secretKey=yyy®ion=us-east-1&endpoint=" +
s3.endpoint());
+ }
+ };
+ }
+
+ @Test
+ void bodyLongerThanAStaleContentLengthHeaderIsStoredWhole() {
+ String text = "a body made longer by the route";
+
+ Exchange result = put("changed.txt", 10L, text);
+
+ assertNull(result.getException());
+ assertEquals(text, stored("changed.txt"), "the whole body must be
stored, not the length of the header");
+ }
+
+ @Test
+ void bodyShorterThanAStaleContentLengthHeaderIsStoredWithItsLength() {
+ Exchange result = put("short.txt", 1000L,
"short".getBytes(StandardCharsets.UTF_8));
+
+ assertNull(result.getException(), "a body shorter than the header must
be stored");
+ assertEquals("short", stored("short.txt"));
+ }
+
+ @Test
+ void emptyBodyStoresItsLengthAsContentLengthMetadata() {
+ Exchange result = put("empty.txt", 10L, new byte[0]);
+
+ assertNull(result.getException());
+ assertEquals("", stored("empty.txt"));
+ assertEquals("0",
s3.userMetadata("empty.txt").get("x-amz-meta-content-length"),
+ "the content-length metadata must be the length of the upload,
not the stale header");
+ }
+
+ @Test
+ void fileBodyIsStoredWithItsLength() throws Exception {
+ File file = Files.writeString(dir.resolve("data.txt"), "the content of
the file").toFile();
+
+ Exchange result = put("file.txt", 3L, file);
+
+ assertNull(result.getException());
+ assertEquals("the content of the file", stored("file.txt"));
+ }
+
+ @Test
+ void streamOfUnknownLengthUsesTheContentLengthHeader() {
+ // control: the case the header is for, a stream whose length is not
known
+ byte[] data = "a stream of unknown
length".getBytes(StandardCharsets.UTF_8);
+ InputStream stream = new FilterInputStream(new
ByteArrayInputStream(data)) {
+ };
+
+ Exchange result = put("stream.txt", (long) data.length, stream);
+
+ assertNull(result.getException());
+ assertEquals("a stream of unknown length", stored("stream.txt"));
+ }
+
+ private Exchange put(String key, long contentLengthHeader, Object body) {
+ return template.send("direct:put", exchange -> {
+ exchange.getIn().setHeader(MinioConstants.OBJECT_NAME, key);
+ exchange.getIn().setHeader(MinioConstants.CONTENT_LENGTH,
contentLengthHeader);
+ exchange.getIn().setBody(body);
+ });
+ }
+
+ private String stored(String key) {
+ byte[] content = s3.content(key);
+ return content != null ? new String(content, StandardCharsets.UTF_8) :
null;
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index e97822ca9b00..a2e88f6e9c37 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -2010,6 +2010,14 @@ producer now uploads it with that length, and the
`CamelAwsS3ContentLength` head
length is not known. Previously the header took precedence: after a route
changed the body of an object read by the
aws2-s3 consumer (which sets the header), a longer body was stored cut to the
old length and a shorter one failed.
+=== camel-minio - the producer stores the whole body when the content-length
header is stale
+
+When the length of the body is known without reading it (a file, `byte[]`,
`String`, `Path` or cached stream), the
+producer now uploads it with that length, and the `CamelMinioContentLength`
header is only used for a stream whose
+length is not known. Previously the header took precedence, also over the
length of a `java.io.File` body: after a
+route changed the body of an object read by the minio consumer (which sets the
header), a longer body was stored cut
+to the old length and a shorter one failed.
+
=== camel-mina - the host name of the server is verified over SSL
When `sslContextParameters` is set (on the endpoint, or with
`useGlobalSslContextParameters` on the component), the