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 cffddeb98234 CAMEL-24996: camel-core - Changing a header value through
the entry set of a copied message changes the original message (#26852)
cffddeb98234 is described below
commit cffddeb982345a62aae580f8f44eca52ef3453bd
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Sep 26 21:25:39 2026 +0200
CAMEL-24996: camel-core - Changing a header value through the entry set of
a copied message changes the original message (#26852)
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 | 56 +++++++++++++++++
.../processor/SplitterHeaderEntrySetValueTest.java | 62 +++++++++++++++++++
.../camel/support/CopyOnWriteHeadersMap.java | 70 +++++++++++++++++++++-
3 files changed, 187 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..d52a896dd7c0 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,62 @@ 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"));
+ }
+
+ @Test
+ public void testCopyOnWriteEntrySetTypedToArraySetValue() {
+ DefaultMessage original = new DefaultMessage(camelContext);
+ original.setHeader("foo", "bar");
+
+ DefaultMessage copy = new DefaultMessage(camelContext);
+ copy.copyFrom(original);
+
+ // the same through the entries from toArray with a typed array
+ @SuppressWarnings("unchecked")
+ Map.Entry<String, Object>[] entries =
copy.getHeaders().entrySet().toArray(new Map.Entry[0]);
+ for (Map.Entry<String, Object> entry : entries) {
+ 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..af01799945d7 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;
@@ -482,6 +485,55 @@ final class CopyOnWriteHeadersMap implements Map<String,
Object> {
}
}
+ /**
+ * 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();
+ }
+ }
+
/**
* A COW-aware Set wrapper for entrySet() that triggers copy-on-write for
mutating operations.
*/
@@ -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