This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-24996
in repository https://gitbox.apache.org/repos/asf/camel.git

commit a3dc3ddedead081f4d2e2c39c3f040178323d0c8
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Sep 24 13:56:54 2026 +0200

    CAMEL-24996: camel-core - Changing a header value through the entry set of 
a copied message changes the original message
    
    A copied message shares its headers with the original through
    CopyOnWriteHeadersMap until one of them writes. The entry set of a
    shared map returned the entries of the shared map itself, so
    entry.setValue() changed the shared map without copying it first. For
    example a split sub-message changed the header of the parent message
    and of the other sub-messages. The entries of a shared map are now
    wrapped, so setValue copies the map first.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../camel/impl/DefaultMessageHeaderTest.java       | 37 ++++++++++++
 .../processor/SplitterHeaderEntrySetValueTest.java | 62 +++++++++++++++++++
 .../camel/support/CopyOnWriteHeadersMap.java       | 70 +++++++++++++++++++++-
 3 files changed, 168 insertions(+), 1 deletion(-)

diff --git 
a/core/camel-core/src/test/java/org/apache/camel/impl/DefaultMessageHeaderTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/impl/DefaultMessageHeaderTest.java
index c12174e63754..24d8e46c747e 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/impl/DefaultMessageHeaderTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/impl/DefaultMessageHeaderTest.java
@@ -774,6 +774,43 @@ public class DefaultMessageHeaderTest {
         assertEquals(1, copy.getHeaders().size());
     }
 
+    @Test
+    public void testCopyOnWriteEntrySetValue() {
+        DefaultMessage original = new DefaultMessage(camelContext);
+        original.setHeader("foo", "bar");
+
+        DefaultMessage copy = new DefaultMessage(camelContext);
+        copy.copyFrom(original);
+
+        // changing a value through the entry set of the copy must not change 
the original
+        for (Map.Entry<String, Object> entry : copy.getHeaders().entrySet()) {
+            assertEquals("bar", entry.setValue("changed"));
+            assertEquals("changed", entry.getValue());
+        }
+
+        assertEquals("changed", copy.getHeader("foo"));
+        assertEquals("bar", original.getHeader("foo"));
+    }
+
+    @Test
+    public void testCopyOnWriteEntrySetToArraySetValue() {
+        DefaultMessage original = new DefaultMessage(camelContext);
+        original.setHeader("foo", "bar");
+
+        DefaultMessage copy = new DefaultMessage(camelContext);
+        copy.copyFrom(original);
+
+        // the same through the entries from toArray
+        for (Object o : copy.getHeaders().entrySet().toArray()) {
+            @SuppressWarnings("unchecked")
+            Map.Entry<String, Object> entry = (Map.Entry<String, Object>) o;
+            entry.setValue("changed");
+        }
+
+        assertEquals("changed", copy.getHeader("foo"));
+        assertEquals("bar", original.getHeader("foo"));
+    }
+
     // ========== Lazy populated headers tests ==========
 
     private static class LazyPopulatedMessage extends DefaultMessage {
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/SplitterHeaderEntrySetValueTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/SplitterHeaderEntrySetValueTest.java
new file mode 100644
index 000000000000..b80fd8af698f
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/SplitterHeaderEntrySetValueTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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;
+
+import java.util.Map;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+
+/**
+ * A split sub-message that changes a header value through the entry set of 
its headers does not change the header of
+ * the parent message or of the other sub-messages.
+ */
+public class SplitterHeaderEntrySetValueTest extends ContextTestSupport {
+
+    @Test
+    public void testEntrySetValueIsolated() throws Exception {
+        
getMockEndpoint("mock:split").expectedHeaderValuesReceivedInAnyOrder("foo", 
"A", "B", "C");
+        getMockEndpoint("mock:result").expectedHeaderReceived("foo", "parent");
+
+        template.sendBodyAndHeader("direct:start", "A,B,C", "foo", "parent");
+
+        assertMockEndpointsSatisfied();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:start")
+                        .split(body())
+                        .process(e -> {
+                            String body = e.getMessage().getBody(String.class);
+                            for (Map.Entry<String, Object> entry : 
e.getMessage().getHeaders().entrySet()) {
+                                if ("foo".equals(entry.getKey())) {
+                                    entry.setValue(body);
+                                }
+                            }
+                        })
+                        .to("mock:split")
+                        .end()
+                        .to("mock:result");
+            }
+        };
+    }
+}
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/CopyOnWriteHeadersMap.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/CopyOnWriteHeadersMap.java
index 7175658cbb95..b3cb5dd30d99 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/CopyOnWriteHeadersMap.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/CopyOnWriteHeadersMap.java
@@ -16,9 +16,12 @@
  */
 package org.apache.camel.support;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.function.BiConsumer;
 import java.util.function.BiFunction;
@@ -485,6 +488,55 @@ final class CopyOnWriteHeadersMap implements Map<String, 
Object> {
     /**
      * A COW-aware Set wrapper for entrySet() that triggers copy-on-write for 
mutating operations.
      */
+    /**
+     * An entry of the shared map, which copies the map before its value is 
set.
+     */
+    private final class CopyOnWriteEntry implements Entry<String, Object> {
+        private final Entry<String, Object> entry;
+        private Object value;
+        private boolean valueSet;
+
+        private CopyOnWriteEntry(Entry<String, Object> entry) {
+            this.entry = entry;
+        }
+
+        @Override
+        public String getKey() {
+            return entry.getKey();
+        }
+
+        @Override
+        public Object getValue() {
+            return valueSet ? value : entry.getValue();
+        }
+
+        @Override
+        public Object setValue(Object value) {
+            Object old = getValue();
+            ensureWritable();
+            delegate.put(entry.getKey(), value);
+            this.value = value;
+            this.valueSet = true;
+            return old;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            return o instanceof Entry<?, ?> e && Objects.equals(getKey(), 
e.getKey())
+                    && Objects.equals(getValue(), e.getValue());
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
+        }
+
+        @Override
+        public String toString() {
+            return getKey() + "=" + getValue();
+        }
+    }
+
     private class CopyOnWriteEntrySet implements Set<Entry<String, Object>> {
 
         // Read operations - no COW trigger
@@ -505,14 +557,27 @@ final class CopyOnWriteHeadersMap implements Map<String, 
Object> {
 
         @Override
         public Object[] toArray() {
+            if (shared) {
+                // the entries of a shared map must be wrapped, so setValue 
does not change the shared map
+                return toList().toArray();
+            }
             return delegate.entrySet().toArray();
         }
 
         @Override
         public <T> T[] toArray(T[] a) {
+            if (shared) {
+                return toList().toArray(a);
+            }
             return delegate.entrySet().toArray(a);
         }
 
+        private List<Entry<String, Object>> toList() {
+            List<Entry<String, Object>> list = new 
ArrayList<>(delegate.size());
+            iterator().forEachRemaining(list::add);
+            return list;
+        }
+
         @Override
         public boolean containsAll(Collection<?> c) {
             return delegate.entrySet().containsAll(c);
@@ -570,6 +635,9 @@ final class CopyOnWriteHeadersMap implements Map<String, 
Object> {
         @Override
         public Iterator<Entry<String, Object>> iterator() {
             final Iterator<Entry<String, Object>> iter = 
delegate.entrySet().iterator();
+            // the entries of a shared map are wrapped, so setValue copies the 
map first instead of changing the
+            // shared map (the map this iterator iterates does not change, 
even if this map is copied meanwhile)
+            final boolean wrap = shared;
             return new Iterator<Entry<String, Object>>() {
                 private Entry<String, Object> lastReturned;
 
@@ -581,7 +649,7 @@ final class CopyOnWriteHeadersMap implements Map<String, 
Object> {
                 @Override
                 public Entry<String, Object> next() {
                     lastReturned = iter.next();
-                    return lastReturned;
+                    return wrap ? new CopyOnWriteEntry(lastReturned) : 
lastReturned;
                 }
 
                 @Override

Reply via email to