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 235655c8af9f CAMEL-26290: camel-util - CaseInsensitiveMap hash by code 
point, FilterIterator.remove after hasNext, TimeUtils.isPositive for negative 
durations (#28643)
235655c8af9f is described below

commit 235655c8af9f3ec64575cb833a6b1aede83dd706
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Oct 29 13:32:21 2026 +0530

    CAMEL-26290: camel-util - CaseInsensitiveMap hash by code point, 
FilterIterator.remove after hasNext, TimeUtils.isPositive for negative 
durations (#28643)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/util/CaseInsensitiveMap.java  | 12 ++-
 .../java/org/apache/camel/util/FilterIterator.java | 16 ++++
 .../main/java/org/apache/camel/util/TimeUtils.java |  2 +-
 .../CaseInsensitiveMapSupplementaryCaseTest.java   | 95 +++++++++++++++++++++
 .../camel/util/FilterIteratorRemoveTest.java       | 97 ++++++++++++++++++++++
 .../java/org/apache/camel/util/TimeUtilsTest.java  | 27 +++++-
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    | 13 +++
 7 files changed, 255 insertions(+), 7 deletions(-)

diff --git 
a/core/camel-util/src/main/java/org/apache/camel/util/CaseInsensitiveMap.java 
b/core/camel-util/src/main/java/org/apache/camel/util/CaseInsensitiveMap.java
index 38e2a58e3cf2..8919c1140b83 100644
--- 
a/core/camel-util/src/main/java/org/apache/camel/util/CaseInsensitiveMap.java
+++ 
b/core/camel-util/src/main/java/org/apache/camel/util/CaseInsensitiveMap.java
@@ -141,14 +141,22 @@ public class CaseInsensitiveMap extends 
AbstractMap<String, Object> implements S
         return Math.max(DEFAULT_CAPACITY, n + 1);
     }
 
+    /**
+     * Hash consistent with {@link String#equalsIgnoreCase(String)} for keys 
without unpaired surrogates: as
+     * equalsIgnoreCase, it folds a surrogate pair as one supplementary code 
point (such as U+10400 and U+10428), not as
+     * two separate chars.
+     */
     static int caseInsensitiveHash(String key) {
         int h = 0;
         for (int i = 0, len = key.length(); i < len; i++) {
-            char c = key.charAt(i);
+            int c = key.charAt(i);
             if (c >= 'A' && c <= 'Z') {
                 c += 32; // fast ASCII upper-to-lower
             } else if (c >= 128) {
-                // full Unicode two-step fold for non-ASCII
+                if (Character.isHighSurrogate((char) c) && i + 1 < len && 
Character.isLowSurrogate(key.charAt(i + 1))) {
+                    c = Character.toCodePoint((char) c, key.charAt(++i));
+                }
+                // full Unicode two-step fold for non-ASCII, as 
String.equalsIgnoreCase does
                 c = Character.toLowerCase(Character.toUpperCase(c));
             }
             h = 31 * h + c;
diff --git 
a/core/camel-util/src/main/java/org/apache/camel/util/FilterIterator.java 
b/core/camel-util/src/main/java/org/apache/camel/util/FilterIterator.java
index 2ae47f899927..5482f7188eff 100644
--- a/core/camel-util/src/main/java/org/apache/camel/util/FilterIterator.java
+++ b/core/camel-util/src/main/java/org/apache/camel/util/FilterIterator.java
@@ -31,6 +31,8 @@ public class FilterIterator<T> implements Iterator<T>, 
Closeable {
     private final Predicate<T> filter;
     private T next;
     private boolean closed;
+    // true if remove() may be called to remove the element last returned by 
next()
+    private boolean canRemove;
 
     public FilterIterator(Iterator<T> it) {
         this(it, null);
@@ -55,6 +57,8 @@ public class FilterIterator<T> implements Iterator<T>, 
Closeable {
     @Override
     public boolean hasNext() {
         if (next == null) {
+            // looking ahead advances the underlying iterator past the element 
returned by next()
+            canRemove = false;
             next = checkNext();
         }
         return next != null;
@@ -68,13 +72,25 @@ public class FilterIterator<T> implements Iterator<T>, 
Closeable {
         if (next != null) {
             T ep = next;
             next = null;
+            canRemove = true;
             return ep;
         }
         throw new NoSuchElementException();
     }
 
+    /**
+     * Removes the last element returned by {@link #next()} from the 
underlying iterator.
+     *
+     * @throws IllegalStateException if {@link #next()} has not been called, 
{@link #remove()} has already been called
+     *                               after the last call to {@link #next()}, 
or {@link #hasNext()} has been called after
+     *                               it (the look-ahead has advanced the 
underlying iterator past that element)
+     */
     @Override
     public void remove() {
+        if (!canRemove) {
+            throw new IllegalStateException("remove() is only supported 
directly after next()");
+        }
+        canRemove = false;
         it.remove();
     }
 
diff --git a/core/camel-util/src/main/java/org/apache/camel/util/TimeUtils.java 
b/core/camel-util/src/main/java/org/apache/camel/util/TimeUtils.java
index 5dbfa3a28900..91b699f25c2e 100644
--- a/core/camel-util/src/main/java/org/apache/camel/util/TimeUtils.java
+++ b/core/camel-util/src/main/java/org/apache/camel/util/TimeUtils.java
@@ -33,7 +33,7 @@ public final class TimeUtils {
     }
 
     public static boolean isPositive(Duration dur) {
-        return dur.getSeconds() > 0 || dur.getNano() != 0;
+        return !dur.isNegative() && !dur.isZero();
     }
 
     /**
diff --git 
a/core/camel-util/src/test/java/org/apache/camel/util/CaseInsensitiveMapSupplementaryCaseTest.java
 
b/core/camel-util/src/test/java/org/apache/camel/util/CaseInsensitiveMapSupplementaryCaseTest.java
new file mode 100644
index 000000000000..dedd24bf699a
--- /dev/null
+++ 
b/core/camel-util/src/test/java/org/apache/camel/util/CaseInsensitiveMapSupplementaryCaseTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.util;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Keys that are equal by {@link String#equalsIgnoreCase(String)} must be the 
same key in the map, also when they differ
+ * in the case of a supplementary character (a surrogate pair), which 
equalsIgnoreCase folds as one code point.
+ */
+class CaseInsensitiveMapSupplementaryCaseTest {
+
+    // DESERET CAPITAL LETTER LONG I (U+10400) and DESERET SMALL LETTER LONG I 
(U+10428)
+    private static final String UPPER = new String(Character.toChars(0x10400));
+    private static final String LOWER = new String(Character.toChars(0x10428));
+
+    @Test
+    void supplementaryLetterInTheOtherCaseIsTheSameKey() {
+        assertTrue(UPPER.equalsIgnoreCase(LOWER));
+
+        CaseInsensitiveMap map = new CaseInsensitiveMap();
+        map.put("X-" + UPPER + "-Name", "a");
+        assertEquals("a", map.get("x-" + LOWER + "-name"));
+        assertTrue(map.containsKey("X-" + LOWER + "-NAME"));
+
+        map.put("x-" + LOWER + "-name", "b");
+        assertEquals(1, map.size());
+        assertEquals("b", map.get("X-" + UPPER + "-Name"));
+        assertEquals("b", map.remove("X-" + LOWER + "-Name"));
+        assertTrue(map.isEmpty());
+    }
+
+    @Test
+    void hashIsTheSameForEveryCodePointAndItsCaseVariants() {
+        Set<String> inconsistent = new LinkedHashSet<>();
+        for (int cp = 0; cp <= Character.MAX_CODE_POINT; cp++) {
+            String a = new String(Character.toChars(cp));
+            int[] variants = {
+                    Character.toUpperCase(cp), Character.toLowerCase(cp), 
Character.toTitleCase(cp),
+                    Character.toLowerCase(Character.toUpperCase(cp)), 
Character.toUpperCase(Character.toLowerCase(cp)) };
+            for (int v : variants) {
+                String b = new String(Character.toChars(v));
+                if (a.equalsIgnoreCase(b)
+                        && CaseInsensitiveMap.caseInsensitiveHash(a) != 
CaseInsensitiveMap.caseInsensitiveHash(b)) {
+                    inconsistent.add(Integer.toHexString(cp) + "/" + 
Integer.toHexString(v));
+                }
+            }
+        }
+        assertTrue(inconsistent.isEmpty(), inconsistent.size() + " pairs equal 
ignoring case hash differently, such as "
+                                           + 
inconsistent.stream().limit(10).toList());
+    }
+
+    @Test
+    void asciiAndBmpKeysKeepTheirBehaviour() {
+        CaseInsensitiveMap map = new CaseInsensitiveMap();
+        map.put("Content-Type", 1);
+        // Turkish dotless i folds to i, as in equalsIgnoreCase
+        map.put("t\u0131tle", 2);
+        // KELVIN SIGN folds to k
+        map.put("\u212Aey", 3);
+        map.put("\u00C9t\u00E9", 4);
+        map.put("\uD801", 5); // a lone surrogate is a key of its own
+
+        assertEquals(1, map.get("content-type"));
+        assertEquals(2, map.get("TITLE"));
+        assertEquals(2, map.get("title"));
+        assertEquals(3, map.get("KEY"));
+        assertEquals(4, map.get("\u00E9T\u00C9"));
+        assertEquals(5, map.get("\uD801"));
+        assertEquals(List.of("Content-Type", "t\u0131tle", "\u212Aey", 
"\u00C9t\u00E9", "\uD801"),
+                new ArrayList<>(map.keySet()));
+    }
+}
diff --git 
a/core/camel-util/src/test/java/org/apache/camel/util/FilterIteratorRemoveTest.java
 
b/core/camel-util/src/test/java/org/apache/camel/util/FilterIteratorRemoveTest.java
new file mode 100644
index 000000000000..71c2f189a1fb
--- /dev/null
+++ 
b/core/camel-util/src/test/java/org/apache/camel/util/FilterIteratorRemoveTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.util;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * remove() removes the element last returned by next(); hasNext() looks ahead 
in the underlying iterator, so after it
+ * that element can no longer be removed and remove() must fail instead of 
removing the element looked at.
+ */
+class FilterIteratorRemoveTest {
+
+    @Test
+    void removeAfterHasNextFailsAndRemovesNothing() {
+        List<String> list = new ArrayList<>(List.of("a", "b", "c"));
+        FilterIterator<String> it = new FilterIterator<>(list.iterator());
+
+        assertEquals("a", it.next());
+        assertTrue(it.hasNext());
+        assertThrows(IllegalStateException.class, it::remove);
+        assertEquals(List.of("a", "b", "c"), list);
+        assertEquals("b", it.next());
+    }
+
+    @Test
+    void removeBeforeNextFails() {
+        List<String> list = new ArrayList<>(List.of("a", "b"));
+        FilterIterator<String> it = new FilterIterator<>(list.iterator());
+
+        assertThrows(IllegalStateException.class, it::remove);
+        assertTrue(it.hasNext());
+        assertThrows(IllegalStateException.class, it::remove);
+        assertEquals(List.of("a", "b"), list);
+    }
+
+    @Test
+    void removeTwiceFails() {
+        List<String> list = new ArrayList<>(List.of("a", "b"));
+        FilterIterator<String> it = new FilterIterator<>(list.iterator());
+
+        assertEquals("a", it.next());
+        it.remove();
+        assertThrows(IllegalStateException.class, it::remove);
+        assertEquals(List.of("b"), list);
+    }
+
+    @Test
+    void removeDirectlyAfterNextRemovesTheReturnedElement() {
+        List<String> list = new ArrayList<>(List.of("a", "b", "c", "d"));
+        FilterIterator<String> it = new FilterIterator<>(list.iterator(), s -> 
!"b".equals(s));
+
+        List<String> seen = new ArrayList<>();
+        while (it.hasNext()) {
+            String s = it.next();
+            seen.add(s);
+            if (!"d".equals(s)) {
+                it.remove();
+            }
+        }
+        assertEquals(List.of("a", "c", "d"), seen);
+        assertEquals(List.of("b", "d"), list);
+        assertFalse(it.hasNext());
+    }
+
+    @Test
+    void removeAfterNextThatSkippedFilteredElements() {
+        List<String> list = new ArrayList<>(List.of("a", "b", "c"));
+        FilterIterator<String> it = new FilterIterator<>(list.iterator(), s -> 
!"b".equals(s));
+
+        assertEquals("a", it.next());
+        assertEquals("c", it.next());
+        it.remove();
+        assertEquals(List.of("a", "b"), list);
+    }
+}
diff --git 
a/core/camel-util/src/test/java/org/apache/camel/util/TimeUtilsTest.java 
b/core/camel-util/src/test/java/org/apache/camel/util/TimeUtilsTest.java
index aae6263ea47f..dcfecfdd8ec8 100644
--- a/core/camel-util/src/test/java/org/apache/camel/util/TimeUtilsTest.java
+++ b/core/camel-util/src/test/java/org/apache/camel/util/TimeUtilsTest.java
@@ -22,6 +22,7 @@ import java.util.Date;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class TimeUtilsTest {
@@ -103,13 +104,31 @@ public class TimeUtilsTest {
     }
 
     @Test
-    void testDurationMatchesExpectWithDate() throws InterruptedException {
-        Date startTime = new Date();
-
-        Thread.sleep(Duration.ofSeconds(1).toMillis());
+    void testDurationMatchesExpectWithDate() {
+        // an event one second ago, rather than sleeping for one second
+        Date startTime = new Date(System.currentTimeMillis() - 
Duration.ofSeconds(1).toMillis());
 
         long taken = System.currentTimeMillis() - startTime.getTime();
         assertTrue(taken >= 1000, "Elapsed time should be equal to or greater 
than 1000 ms but was " + taken);
         assertTrue(taken < 1500, "Elapsed time should be smaller than 1500 ms 
but was " + taken);
+        assertEquals("1s", TimeUtils.printSince(startTime.getTime()));
+    }
+
+    @Test
+    void isPositiveIsFalseForNegativeDurationsWithANanosPart() {
+        // stored as seconds = -1 and nanos = 999999999
+        assertFalse(TimeUtils.isPositive(Duration.ofNanos(-1)));
+        assertFalse(TimeUtils.isPositive(Duration.ofNanos(-999_999_999)));
+        assertFalse(TimeUtils.isPositive(Duration.ofNanos(-1_000_000_001)));
+        assertFalse(TimeUtils.isPositive(Duration.ofMillis(-1500)));
+    }
+
+    @Test
+    void isPositiveForZeroPositiveAndWholeNegativeDurations() {
+        assertFalse(TimeUtils.isPositive(Duration.ZERO));
+        assertFalse(TimeUtils.isPositive(Duration.ofSeconds(-1)));
+        assertTrue(TimeUtils.isPositive(Duration.ofNanos(1)));
+        assertTrue(TimeUtils.isPositive(Duration.ofSeconds(1)));
+        assertTrue(TimeUtils.isPositive(Duration.ofMillis(1500)));
     }
 }
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 f571efbce259..0d6dd068dcbb 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
@@ -157,6 +157,19 @@ graphql, kubernetes-custom-resources and typesafe-ai) now 
have type converters t
 or `InputStream`, so such a body sent to http or a file failed. A `JsonObject` 
or `JsonArray` that holds a value that
 is not JSON, such as a POJO, converts as before.
 
+=== camel-util - FilterIterator.remove after hasNext throws 
IllegalStateException
+
+`FilterIterator.hasNext()` looks ahead in the iterator it wraps, so a 
`remove()` after `next()` and then `hasNext()`
+removed the element after the one `next()` returned, and a `remove()` before 
any `next()` removed an element as well.
+`remove()` now throws `IllegalStateException` unless it directly follows 
`next()`. Code that called `hasNext()`
+between `next()` and `remove()` must call `remove()` before `hasNext()`.
+
+=== camel-util - CaseInsensitiveMap merges keys that differ only in the case 
of a supplementary letter
+
+`CaseInsensitiveMap`, which holds the message headers, now treats two keys 
that differ only in the case of a letter
+outside the Basic Multilingual Plane (such as U+10400 and U+10428) as the same 
key, as `String.equalsIgnoreCase` does;
+before, they were usually kept as two entries. Keys without such letters are 
not affected.
+
 === camel-main - a failure to start is logged
 
 A Camel Main application that fails to start now logs why, at ERROR: `Error 
starting Camel: ...` with the stack trace,

Reply via email to