You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/18 15:31:16 UTC

(camel) branch main updated: CAMEL-25723: camel-zipfile - ZipAggregationStrategy keeps an entry whose name comes again under a numbered name (#28031)

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 <49...@users.noreply.github.com>
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 <no...@anthropic.com>
---
 .../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