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 959866bd702b CAMEL-25723: camel-zipfile - ZipAggregationStrategy keeps 
an entry whose name comes again under a numbered name (#28031)
959866bd702b is described below

commit 959866bd702b7dd94ecf41bdb22e3c677715439d
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Oct 18 21:01:09 2026 +0530

    CAMEL-25723: camel-zipfile - ZipAggregationStrategy keeps an entry whose 
name comes again under a numbered name (#28031)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/catalog/docs/zipFile-dataformat.adoc     |   4 +
 .../src/main/docs/zipFile-dataformat.adoc          |   4 +
 .../aggregate/zipfile/ZipAggregationStrategy.java  |  31 ++++
 .../ZipAggregationStrategySameFileNameTest.java    | 206 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   8 +
 5 files changed, 253 insertions(+)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/zipFile-dataformat.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/zipFile-dataformat.adoc
index 238261b48da2..eb8970c15a58 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/zipFile-dataformat.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/zipFile-dataformat.adoc
@@ -253,6 +253,10 @@ from("file:input/directory?antInclude=*/.txt")
         .to("file:output/directory");
 ----
 
+When the zip file already has an entry with the same name, for example two 
files `orders.csv` from different folders
+of a recursive file consumer, the later entry is added with a number before 
its extension (`orders (1).csv`,
+`orders (2).csv`, ...), so no content is lost. Use `preserveFolderStructure` 
to keep the folders in the entry names.
+
 == Dependencies
 
 To use Zip files in your camel routes, you need to add a dependency on
diff --git a/components/camel-zipfile/src/main/docs/zipFile-dataformat.adoc 
b/components/camel-zipfile/src/main/docs/zipFile-dataformat.adoc
index 238261b48da2..eb8970c15a58 100644
--- a/components/camel-zipfile/src/main/docs/zipFile-dataformat.adoc
+++ b/components/camel-zipfile/src/main/docs/zipFile-dataformat.adoc
@@ -253,6 +253,10 @@ from("file:input/directory?antInclude=*/.txt")
         .to("file:output/directory");
 ----
 
+When the zip file already has an entry with the same name, for example two 
files `orders.csv` from different folders
+of a recursive file consumer, the later entry is added with a number before 
its extension (`orders (1).csv`,
+`orders (2).csv`, ...), so no content is lost. Use `preserveFolderStructure` 
to keep the folders in the entry names.
+
 == Dependencies
 
 To use Zip files in your camel routes, you need to add a dependency on
diff --git 
a/components/camel-zipfile/src/main/java/org/apache/camel/processor/aggregate/zipfile/ZipAggregationStrategy.java
 
b/components/camel-zipfile/src/main/java/org/apache/camel/processor/aggregate/zipfile/ZipAggregationStrategy.java
index 47713868811f..74de4788693a 100644
--- 
a/components/camel-zipfile/src/main/java/org/apache/camel/processor/aggregate/zipfile/ZipAggregationStrategy.java
+++ 
b/components/camel-zipfile/src/main/java/org/apache/camel/processor/aggregate/zipfile/ZipAggregationStrategy.java
@@ -50,6 +50,11 @@ import org.apache.camel.util.FileUtil;
  * useFilenameHeader is set to true.
  * </p>
  * <p>
+ * When the ZIP file already has an entry with the same name (for example two 
files {@code orders.csv} from different
+ * folders), the new entry gets a number before its extension ({@code orders 
(1).csv}, {@code orders (2).csv}, ...) so
+ * that the earlier entry is kept.
+ * </p>
+ * <p>
  * <b>Note:</b> Please note that this aggregation strategy requires eager 
completion check to work properly.
  * </p>
  */
@@ -316,6 +321,7 @@ public class ZipAggregationStrategy implements 
AggregationStrategy {
             Path parent = dest.getParent();
             if (parent != null) {
                 Files.createDirectories(parent);
+                dest = freeEntryName(dest);
                 Files.copy(file.toPath(), dest, 
StandardCopyOption.REPLACE_EXISTING);
             }
         }
@@ -331,11 +337,36 @@ public class ZipAggregationStrategy implements 
AggregationStrategy {
             Path parent = dest.getParent();
             if (parent != null) {
                 Files.createDirectories(parent);
+                dest = freeEntryName(dest);
                 Files.write(dest, buffer, StandardOpenOption.CREATE, 
StandardOpenOption.TRUNCATE_EXISTING);
             }
         }
     }
 
+    /**
+     * Returns the given entry when the zip file has no entry with that name, 
otherwise the first free name with
+     * {@code " (n)"} before the extension: {@code orders.csv} becomes {@code 
orders (1).csv}, then
+     * {@code orders (2).csv}. Writing to an existing entry would replace it, 
and the content of the earlier message
+     * would be lost. The loop ends: every taken name is an entry of the zip 
file, so at most one more name than there
+     * are entries is checked.
+     */
+    private static Path freeEntryName(Path dest) {
+        if (Files.notExists(dest)) {
+            return dest;
+        }
+        String name = dest.getFileName().toString();
+        int dot = name.lastIndexOf('.');
+        // a leading dot (".profile") is part of the name, not an extension
+        String base = dot > 0 ? name.substring(0, dot) : name;
+        String extension = dot > 0 ? name.substring(dot) : "";
+        for (int i = 1;; i++) {
+            Path candidate = dest.resolveSibling(base + " (" + i + ")" + 
extension);
+            if (Files.notExists(candidate)) {
+                return candidate;
+            }
+        }
+    }
+
     private static URI getZipURI(File zipFile) throws URISyntaxException {
         return new URI("jar", zipFile.toURI().toString(), null);
     }
diff --git 
a/components/camel-zipfile/src/test/java/org/apache/camel/processor/aggregate/zipfile/ZipAggregationStrategySameFileNameTest.java
 
b/components/camel-zipfile/src/test/java/org/apache/camel/processor/aggregate/zipfile/ZipAggregationStrategySameFileNameTest.java
new file mode 100644
index 000000000000..4e2e9a4a8987
--- /dev/null
+++ 
b/components/camel-zipfile/src/test/java/org/apache/camel/processor/aggregate/zipfile/ZipAggregationStrategySameFileNameTest.java
@@ -0,0 +1,206 @@
+/*
+ * 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.processor.aggregate.zipfile;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.camel.test.junit6.TestSupport.deleteDirectory;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Two messages with the same entry name (files with the same name from 
different folders, or the same CamelFileName)
+ * must not replace each other in the zip file: the later one is added as 
{@code name (1).ext}, {@code name (2).ext},
+ * ...
+ */
+class ZipAggregationStrategySameFileNameTest extends CamelTestSupport {
+
+    private static final String DIR = 
"target/ZipAggregationStrategySameFileNameTest";
+
+    @BeforeEach
+    void cleanDirectories() {
+        deleteDirectory(DIR);
+    }
+
+    @Test
+    void secondFileWithSameNameIsAddedUnderANumberedName() throws Exception {
+        write(DIR + "/files/a/orders.csv", "from A");
+        write(DIR + "/files/b/orders.csv", "from B");
+        ZipAggregationStrategy strategy = new ZipAggregationStrategy();
+
+        Exchange answer = strategy.aggregate(null, fileExchange(DIR + 
"/files/a/orders.csv"));
+        answer = strategy.aggregate(answer, fileExchange(DIR + 
"/files/b/orders.csv"));
+
+        assertEquals(List.of("orders (1).csv=from B", "orders.csv=from A"), 
entries(answer.getIn().getBody(File.class)));
+    }
+
+    @Test
+    void secondBodyWithSameFileNameHeaderIsAddedUnderANumberedName() throws 
Exception {
+        ZipAggregationStrategy strategy = new ZipAggregationStrategy(false, 
true);
+
+        Exchange answer = strategy.aggregate(null, bytesExchange("report.txt", 
"first"));
+        answer = strategy.aggregate(answer, bytesExchange("report.txt", 
"second"));
+
+        assertEquals(List.of("report (1).txt=second", "report.txt=first"), 
entries(answer.getIn().getBody(File.class)));
+    }
+
+    @Test
+    void manyFilesWithSameNameAreAllKept() throws Exception {
+        write(DIR + "/files/a/orders.csv", "from A");
+        write(DIR + "/files/b/orders (1).csv", "from B, a file with this 
name");
+        write(DIR + "/files/c/orders.csv", "from C");
+        write(DIR + "/files/d/orders.csv", "from D");
+        write(DIR + "/files/e/orders.csv", "from E");
+        ZipAggregationStrategy strategy = new ZipAggregationStrategy();
+
+        Exchange answer = null;
+        for (String path : List.of("a/orders.csv", "b/orders (1).csv", 
"c/orders.csv", "d/orders.csv", "e/orders.csv")) {
+            answer = strategy.aggregate(answer, fileExchange(DIR + "/files/" + 
path));
+        }
+
+        assertEquals(List.of(
+                "orders (1).csv=from B, a file with this name",
+                "orders (2).csv=from C",
+                "orders (3).csv=from D",
+                "orders (4).csv=from E",
+                "orders.csv=from A"), 
entries(answer.getIn().getBody(File.class)));
+    }
+
+    @Test
+    void namesWithoutExtensionGetTheNumberAtTheEnd() throws Exception {
+        ZipAggregationStrategy strategy = new ZipAggregationStrategy(false, 
true);
+
+        Exchange answer = strategy.aggregate(null, bytesExchange("README", 
"one"));
+        answer = strategy.aggregate(answer, bytesExchange("README", "two"));
+        answer = strategy.aggregate(answer, bytesExchange(".profile", 
"three"));
+        answer = strategy.aggregate(answer, bytesExchange(".profile", "four"));
+
+        assertEquals(List.of(".profile (1)=four", ".profile=three", "README 
(1)=two", "README=one"),
+                entries(answer.getIn().getBody(File.class)));
+    }
+
+    @Test
+    void recursivePollWithTwoFilesOfTheSameNameZipsBoth() throws Exception {
+        // only the two files: the duplicate is the last exchange of the batch 
and completes the group (eager check)
+        write(DIR + "/two/a/orders.csv", "from A");
+        write(DIR + "/two/b/orders.csv", "from B");
+
+        assertEquals(List.of("orders (1).csv=from B", "orders.csv=from A"), 
zipOnce("two"));
+    }
+
+    @Test
+    void recursivePollWithFilesOfTheSameNameZipsAll() throws Exception {
+        write(DIR + "/three/a/orders.csv", "from A");
+        write(DIR + "/three/b/orders.csv", "from B");
+        write(DIR + "/three/c/other.csv", "from C");
+
+        assertEquals(List.of("orders (1).csv=from B", "orders.csv=from A", 
"other.csv=from C"), zipOnce("three"));
+    }
+
+    @Test
+    void preserveFolderStructureKeepsTheFolderNames() throws Exception {
+        // the entry names include the folders, so they differ and are not 
numbered (same result as before the fix)
+        write(DIR + "/preserve/a/orders.csv", "from A");
+        write(DIR + "/preserve/b/orders.csv", "from B");
+
+        assertEquals(List.of("a/orders.csv=from A", "b/orders.csv=from B"), 
zipOnce("preserve"));
+    }
+
+    private List<String> zipOnce(String routeId) throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:" + routeId);
+        mock.expectedMessageCount(1);
+        context.getRouteController().startRoute(routeId);
+        MockEndpoint.assertIsSatisfied(context);
+
+        File[] zips = new File(DIR + "/out-" + routeId).listFiles();
+        assertNotNull(zips);
+        assertEquals(1, zips.length);
+        return entries(zips[0]);
+    }
+
+    private Exchange fileExchange(String path) {
+        Exchange exchange = new DefaultExchange(context);
+        exchange.getIn().setBody(new File(path));
+        return exchange;
+    }
+
+    private Exchange bytesExchange(String fileName, String content) {
+        Exchange exchange = new DefaultExchange(context);
+        exchange.getIn().setBody(content.getBytes(StandardCharsets.UTF_8));
+        exchange.getIn().setHeader(Exchange.FILE_NAME, fileName);
+        return exchange;
+    }
+
+    private static List<String> entries(File zip) throws IOException {
+        List<String> answer = new ArrayList<>();
+        try (ZipInputStream zin = new ZipInputStream(new 
FileInputStream(zip))) {
+            for (ZipEntry ze = zin.getNextEntry(); ze != null; ze = 
zin.getNextEntry()) {
+                if (!ze.isDirectory()) {
+                    answer.add(ze.getName() + "=" + new 
String(zin.readAllBytes(), StandardCharsets.UTF_8));
+                }
+            }
+        }
+        answer.sort(null);
+        return answer;
+    }
+
+    private static void write(String path, String content) throws IOException {
+        Path p = Path.of(path);
+        Files.createDirectories(p.getParent());
+        Files.writeString(p, content, StandardCharsets.UTF_8);
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                zipRoute("two", new ZipAggregationStrategy());
+                zipRoute("three", new ZipAggregationStrategy());
+                zipRoute("preserve", new ZipAggregationStrategy(true));
+            }
+
+            private void zipRoute(String id, ZipAggregationStrategy strategy) {
+                // the documented pattern: one zip per poll of a recursive 
file consumer
+                from("file:" + DIR + "/" + id + 
"?recursive=true&noop=true&sortBy=file:path&initialDelay=0&delay=100")
+                        .routeId(id).autoStartup(false)
+                        .aggregate(constant(true), strategy)
+                        .completionFromBatchConsumer()
+                        .eagerCheckCompletion()
+                        .to("file:" + DIR + "/out-" + id)
+                        .to("mock:" + id);
+            }
+        };
+    }
+}
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 1016dd2e366d..74d6f61a5466 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
@@ -855,6 +855,14 @@ as `Failed to connect, because a certificate or security 
check failed`. To resto
 The certificate that the user name and password are encrypted with is checked 
by the same validator. Endpoints that
 use the security policy `None` are not affected.
 
+=== camel-zipfile - ZipAggregationStrategy keeps entries with the same name
+
+`ZipAggregationStrategy` replaced an entry of the zip file when a later 
message had the same entry name, for example
+two files `orders.csv` from different folders of a recursive file consumer 
(the folders are not kept by default), so
+the content of the earlier file was lost. The later entry is now added with a 
number before its extension:
+`orders (1).csv`, `orders (2).csv`, and so on. Entry names can therefore 
differ from the file names (or the
+`CamelFileName` header). Use `preserveFolderStructure=true` to keep the 
folders in the entry names instead.
+
 === camel-lra - only an LRA hosted by the configured coordinator is joined
 
 The LRA saga service now joins, completes or compensates an LRA only when its 
URL is under the configured LRA

Reply via email to