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&region=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

Reply via email to