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
