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

garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git


The following commit(s) were added to refs/heads/master by this push:
     new fa2470369 Fix Range serialization compatibility and hash 
reconstruction (#1802)
fa2470369 is described below

commit fa2470369bc43d1806f92685d81f49b8d828160d
Author: Gary Gregory <[email protected]>
AuthorDate: Fri Sep 25 15:30:51 2026 -0400

    Fix Range serialization compatibility and hash reconstruction (#1802)
    
    Recompute the transient hash cache after deserialization while retaining
    endpoint and comparator validation. Restore serialVersionUID 1L to
    preserve compatibility with 3.20.0 serialized ranges.
    
    Add regression tests for changing endpoint hashes and legacy streams.
---
 RELEASE-NOTES.txt                                  |   4 +-
 src/changes/changes.xml                            |   3 +-
 src/main/java/org/apache/commons/lang3/Range.java  |  17 +--
 .../apache/commons/lang3/RangeReadObjectTest.java  | 154 ++++++++++++++-------
 4 files changed, 117 insertions(+), 61 deletions(-)

diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
index 9456a9634..5141daec9 100644
--- a/RELEASE-NOTES.txt
+++ b/RELEASE-NOTES.txt
@@ -151,7 +151,7 @@ Fixed Bugs:
 * LANG-1823: LocaleUtils.toLocale cannot parse valid JDK Locale string 
containing '#' #1630. Thanks to ????, Gary Gregory.
 * LANG-879:  LocaleUtils test fails with new Locale "ja_JP_JP_#u-ca-japanese" 
of JDK7. Thanks to Matthew T. Adams, Gary Gregory.
 *            Fix typo in SystemProperties.JDK_XML_ENTITY_REPLACEMENT_LIMIT 
(#1631). Thanks to Omkhar Arasaratnam, Gary Gregory.
-*            Harden Range.readObject() to reject bad cached hash code (#1633). 
Thanks to Omkhar Arasaratnam, Gary Gregory.
+*            Recompute Range cached hash codes on deserialization while 
retaining endpoint and comparator validation (#1633). Thanks to Omkhar 
Arasaratnam, Gary Gregory.
 *            Harden Fraction.readObject() to reject bad cached hash code 
(#1634). Thanks to Omkhar Arasaratnam, Gary Gregory.
 *            NumberUtils.createNumber(String): Float shortcut can bypass exact 
decimal parsing (#1635). Thanks to Omkhar Arasaratnam, Gary Gregory.
 *            StringUtils.joins() for primitive types can throw OOME before 
index check (#1636). Thanks to Omkhar Arasaratnam, Gary Gregory.
@@ -306,6 +306,8 @@ Fixed Bugs:
 *            Fix NumberUtils.createNumber() and isCreatable() for positive 
Long literals with explicit '+' sign (#1796). Thanks to gaurav kumar pandey, 
Gary Gregory.
 *            Fix StrBuilder.lastIndexOf(String) start index for an empty 
search (#1759). Thanks to alhudz, Gary Gregory.
 *            Fix int overflow in Conversion count and position bounds guards 
(#1779). Thanks to alhudz, Gary Gregory.
+*            Preserve serialization compatibility with 3.20.0 for Range, 
IntegerRange, LongRange, and DoubleRange by retaining Range serialVersionUID 1L.
+             Streams written by 3.21.0 RC1 with UID 2L require reading with 
RC1 and exporting endpoint and comparator values before rebuilding ranges with 
this release.
 
 Changes:
 *            Bump org.apache.commons:commons-parent from 92 to 105 #1498. 
Thanks to Gary Gregory, Dependabot.
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index d2347420c..389a29481 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -137,7 +137,7 @@ The <action> type attribute can be add,update,fix,remove.
     <action issue="LANG-1823" type="fix" dev="ggregory" due-to="尹茂椿萱, Gary 
Gregory">LocaleUtils.toLocale cannot parse valid JDK Locale string containing 
'#' #1630.</action>
     <action issue="LANG-879"  type="fix" dev="ggregory" due-to="Matthew T. 
Adams, Gary Gregory">LocaleUtils test fails with new Locale 
"ja_JP_JP_#u-ca-japanese" of JDK7.</action>
     <action                   type="fix" dev="ggregory" due-to="Omkhar 
Arasaratnam, Gary Gregory">Fix typo in 
SystemProperties.JDK_XML_ENTITY_REPLACEMENT_LIMIT (#1631).</action>
-    <action                   type="fix" dev="ggregory" due-to="Omkhar 
Arasaratnam, Gary Gregory">Harden Range.readObject() to reject bad cached hash 
code (#1633).</action>
+    <action                   type="fix" dev="ggregory" due-to="Omkhar 
Arasaratnam, Gary Gregory">Recompute Range cached hash codes on deserialization 
while retaining endpoint and comparator validation (#1633).</action>
     <action                   type="fix" dev="ggregory" due-to="Omkhar 
Arasaratnam, Gary Gregory">Harden Fraction.readObject() to reject bad cached 
hash code (#1634).</action>
     <action                   type="fix" dev="ggregory" due-to="Omkhar 
Arasaratnam, Gary Gregory">NumberUtils.createNumber(String): Float shortcut can 
bypass exact decimal parsing (#1635).</action>
     <action                   type="fix" dev="ggregory" due-to="Omkhar 
Arasaratnam, Gary Gregory">StringUtils.joins() for primitive types can throw 
OOME before index check (#1636).</action>
@@ -316,6 +316,7 @@ The <action> type attribute can be add,update,fix,remove.
     <action                   type="add" dev="ggregory" due-to="Gary Gregory, 
gaurav kumar pandey">Fix TypeUtils.isAssignable() for wildcards with multiple 
upper bounds (#1782).</action>
     <action                   type="add" dev="ggregory" due-to="Gary 
Gregory">Add JavaVersion.JAVA_28.</action>
     <action                   type="add" dev="ggregory" due-to="Gary 
Gregory">Add SystemUtils.IS_JAVA_28.</action>
+    <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">Preserve serialization compatibility with 3.20.0 for Range, 
IntegerRange, LongRange, and DoubleRange by retaining Range serialVersionUID 
1L. Streams written by 3.21.0 RC1 with UID 2L require reading with RC1 and 
exporting endpoint and comparator values before rebuilding ranges with this 
release.</action>
     <!-- UPDATE -->
     <action                   type="update" dev="ggregory" due-to="Gary 
Gregory, Dependabot">Bump org.apache.commons:commons-parent from 92 to 105 
#1498.</action>
     <action                   type="update" dev="ggregory" due-to="Gary 
Gregory">[test] Bump org.apache.commons:commons-text from 1.14.0 to 
1.15.0.</action>
diff --git a/src/main/java/org/apache/commons/lang3/Range.java 
b/src/main/java/org/apache/commons/lang3/Range.java
index 953ab5b1c..b1ca03e71 100644
--- a/src/main/java/org/apache/commons/lang3/Range.java
+++ b/src/main/java/org/apache/commons/lang3/Range.java
@@ -37,7 +37,6 @@
  *
  * @param <T> The type of range values.
  * @since 3.0
- * @since 3.21.0 {@code serialVersionUID} changed from {@code 1L} to {@code 
2L}.
  */
 public class Range<T> implements Serializable {
 
@@ -62,9 +61,8 @@ public int compare(final Object obj1, final Object obj2) {
      * Serialization version.
      *
      * @see java.io.Serializable
-     * @since 3.21.0 {@code serialVersionUID} changed from {@code 1L} to 
{@value}.
      */
-    private static final long serialVersionUID = 2L;
+    private static final long serialVersionUID = 1L;
 
     /**
      * Creates a range with the specified minimum and maximum values (both 
inclusive).
@@ -257,7 +255,7 @@ private static void requireNotNaN(final Object element, 
final String name) {
     /**
      * Cached output hashCode (class is immutable).
      */
-    private final int hashCode;
+    private transient int hashCode;
 
     /**
      * The maximum value in this range (inclusive).
@@ -613,21 +611,15 @@ public boolean isStartedBy(final T element) {
     }
 
     /**
-     * Validates the cached hashCode after deserialization. Throws a {@link 
InvalidObjectException} when the stored hashCode does not match the canonical 
hash
-     * of the deserialized minimum/maximum.
+     * Validates the endpoints and comparator and recomputes the cached hash 
code after deserialization.
      *
      * @param in See {@link Serializable}.
      * @throws IOException Thrown as described in {@link Serializable}.
      * @throws ClassNotFoundException Thrown as described in {@link 
Serializable}.
-     * @throws InvalidObjectException Thrown if the hashCode doesn't match the 
minimum and maximum.
+     * @throws InvalidObjectException Thrown if the endpoints or comparator 
violate the range invariants.
      */
     private void readObject(final ObjectInputStream in) throws IOException, 
ClassNotFoundException {
         in.defaultReadObject();
-        // Reject streams whose cached hashCode does not match the canonical 
hash of the deserialized minimum/maximum: a crafted stream cannot supply a 
forged
-        // value.
-        if (hashCode != hash(minimum, maximum)) {
-            throw new InvalidObjectException("Range hashCode does not match 
minimum/maximum.");
-        }
         SerializationUtils.requireNonNull(maximum, "maximum null");
         SerializationUtils.requireNonNull(minimum, "minimum null");
         SerializationUtils.requireNonNull(comparator, "comparator null");
@@ -639,6 +631,7 @@ private void readObject(final ObjectInputStream in) throws 
IOException, ClassNot
         if (comparator.compare(minimum, maximum) > 0) {
             throw new InvalidObjectException("Range minimum is greater than 
maximum under the comparator.");
         }
+        hashCode = hash(minimum, maximum);
     }
 
     /**
diff --git a/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java 
b/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
index e2a0cdc07..90e32ab00 100644
--- a/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
+++ b/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
@@ -18,7 +18,7 @@
 package org.apache.commons.lang3;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -29,6 +29,7 @@
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.io.Serializable;
+import java.util.Base64;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.Objects;
@@ -37,10 +38,43 @@
 import org.junit.jupiter.api.Test;
 
 /**
- * Tests that a serialized {@link Range} can't store a bad cached hashCode.
+ * Tests range invariants and hash code reconstruction during deserialization.
  */
 class RangeReadObjectTest {
 
+    private static final class ChangingHashEndpoint extends IdentityEndpoint {
+        private static final long serialVersionUID = 1L;
+        private transient int hash = 123;
+
+        ChangingHashEndpoint(final int value) {
+            super(value);
+        }
+
+        @Override
+        public boolean equals(final Object other) {
+            return this == other;
+        }
+
+        @Override
+        public int hashCode() {
+            return hash;
+        }
+    }
+
+    private static class IdentityEndpoint implements Serializable, 
Comparable<IdentityEndpoint> {
+        private static final long serialVersionUID = 1L;
+        private final int value;
+
+        IdentityEndpoint(final int value) {
+            this.value = value;
+        }
+
+        @Override
+        public int compareTo(final IdentityEndpoint other) {
+            return Integer.compare(value, other.value);
+        }
+    }
+
     /**
      * Standin class used only to drive {@link 
ObjectOutputStream#writeObject(Object)} into emitting a stream that matches the 
wire format of {@link Range} but
      * with caller-controlled field values. The class name and 
serialVersionUID are spoofed in the stream below via a custom {@link 
ObjectOutputStream} subclass
@@ -48,17 +82,15 @@ class RangeReadObjectTest {
      */
     private static final class RangeForge implements Serializable {
 
-        private static final long serialVersionUID = 2L; // matches 
Range.serialVersionUID
+        private static final long serialVersionUID = 1L; // matches 
Range.serialVersionUID
         private final Object comparator;
-        private final int hashCode;
         private final Object maximum;
         private final Object minimum;
 
-        RangeForge(final Object comparator, final Object minimum, final Object 
maximum, final int hashCode) {
+        RangeForge(final Object comparator, final Object minimum, final Object 
maximum) {
             this.comparator = comparator;
             this.minimum = minimum;
             this.maximum = maximum;
-            this.hashCode = hashCode;
         }
     }
 
@@ -73,7 +105,7 @@ private static Object deserialize(final byte[] bytes) throws 
IOException, ClassN
      * {@link Range}. Because the field set, types, order, and 
serialVersionUID all match, default deserialization assigns each forged value 
to the
      * corresponding Range field via reflection (bypassing the constructor).
      */
-    private static byte[] forgeRangeStream(final Object comparator, final 
Object minimum, final Object maximum, final int hashCode) throws IOException {
+    private static byte[] forgeRangeStream(final Object comparator, final 
Object minimum, final Object maximum) throws IOException {
         // Build the legitimate-shape bytes via RangeForge, then rewrite the 
embedded class name.
         final ByteArrayOutputStream baos = new ByteArrayOutputStream();
         try (ObjectOutputStream oos = new ObjectOutputStream(baos) {
@@ -89,42 +121,76 @@ protected void writeClassDescriptor(final 
java.io.ObjectStreamClass desc) throws
                 }
             }
         }) {
-            oos.writeObject(new RangeForge(comparator, minimum, maximum, 
hashCode));
+            oos.writeObject(new RangeForge(comparator, minimum, maximum));
         }
         return baos.toByteArray();
     }
 
     @Test
-    void testBadHashCodeRejected() throws Exception {
+    void testCachedHashCodeRecomputed() throws Exception {
         final Range<Integer> range = Range.of(1, 100);
-        final byte[] bytes = SerializationUtils.serialize(range);
-        // Locate the legitimate hashCode int in the serialized stream and 
overwrite it.
-        final int hashCode = (Integer) FieldUtils.readDeclaredField(range, 
"hashCode", true);
-        final byte[] edited = SerializationUtilsTest.replaceLastInt(bytes, 
hashCode, 0xDEADBEEF);
-        final SerializationException ex = 
assertThrows(SerializationException.class, () -> 
SerializationUtils.deserialize(edited),
-                "Bad hashCode in stream must be rejected with 
InvalidObjectException");
-        assertInstanceOf(InvalidObjectException.class, ex.getCause());
-        assertEquals("java.io.InvalidObjectException: Range hashCode does not 
match minimum/maximum.", ex.getMessage());
+        FieldUtils.writeDeclaredField(range, "hashCode", 0xDEADBEEF, true);
+        final Range<Integer> copy = SerializationUtils.roundtrip(range);
+        assertEquals(Range.of(1, 100).hashCode(), copy.hashCode());
+        assertEquals(range, copy);
+    }
+
+    @Test
+    void testCloneIdentityHashEndpoints() {
+        final Range<IdentityEndpoint> original = Range.of(new 
IdentityEndpoint(1), new IdentityEndpoint(2));
+        final Range<IdentityEndpoint> copy = 
SerializationUtils.clone(original);
+        assertNotSame(original.getMinimum(), copy.getMinimum());
+        assertNotSame(original.getMaximum(), copy.getMaximum());
+        assertEquals(0, original.getMinimum().compareTo(copy.getMinimum()));
+        assertEquals(0, original.getMaximum().compareTo(copy.getMaximum()));
+        assertEquals(Objects.hash(copy.getMinimum(), copy.getMaximum()), 
copy.hashCode());
     }
 
-    /**
-     * Forged stream with {@code comparator == null}; F-004 hashCode check 
passes because we set hashCode canonically; {@code contains()} then NPEs on
-     * {@code comparator.compare(...)}.
-     */
     @Test
     void testComparatorNullViaForgedStream() throws Exception {
         final Integer min = Integer.valueOf(1);
         final Integer max = Integer.valueOf(10);
-        final int canonicalHash = Objects.hash(min, max);
-        final byte[] forged = forgeRangeStream(null, min, max, canonicalHash);
+        final byte[] forged = forgeRangeStream(null, min, max);
         assertThrows(InvalidObjectException.class, () -> deserialize(forged));
     }
 
-    /**
-     * Forged stream: minimum=1, maximum=10, hashCode=hash(1,10) (all 
legitimate), but comparator replaced with a reversed ordering. The hashCode 
gate passes
-     * (comparator excluded from the hash) and the null gates pass (comparator 
is non-null); the ordering invariant is the only one violated. The deserialized
-     * Range still reports endpoints [1,10] but {@code contains(5)} returns 
false because it trusts the reversed comparator.
-     */
+    @Test
+    void testDeserializeVersion320() {
+        // Streams generated using Commons Lang 3.20.0, with endpoints 1 and 2.
+        final Range<?>[] expected = {Range.of(1, 2), IntegerRange.of(1, 2), 
LongRange.of(1, 2), DoubleRange.of(1, 2)};
+        final String[] streams = {
+            
"rO0ABXNyAB5vcmcuYXBhY2hlLmNvbW1vbnMubGFuZzMuUmFuZ2UAAAAAAAAAAQIAA0wACmNvbXBhcmF0b3J0ABZMamF2YS91dGls"
 +
+                
"L0NvbXBhcmF0b3I7TAAHbWF4aW11bXQAEkxqYXZhL2xhbmcvT2JqZWN0O0wAB21pbmltdW1xAH4AAnhwfnIAM29yZy5hcGFjaGUu"
 +
+                
"Y29tbW9ucy5sYW5nMy5SYW5nZSRDb21wYXJhYmxlQ29tcGFyYXRvcgAAAAAAAAAAEgAAeHIADmphdmEubGFuZy5FbnVtAAAAAAAA"
 +
+                
"AAASAAB4cHQACElOU1RBTkNFc3IAEWphdmEubGFuZy5JbnRlZ2VyEuKgpPeBhzgCAAFJAAV2YWx1ZXhyABBqYXZhLmxhbmcuTnVt"
 +
+                "YmVyhqyVHQuU4IsCAAB4cAAAAAJzcQB+AAgAAAAB",
+            
"rO0ABXNyACVvcmcuYXBhY2hlLmNvbW1vbnMubGFuZzMuSW50ZWdlclJhbmdlAAAAAAAAAAECAAB4cgAkb3JnLmFwYWNoZS5jb21t"
 +
+                
"b25zLmxhbmczLk51bWJlclJhbmdlAAAAAAAAAAECAAB4cgAeb3JnLmFwYWNoZS5jb21tb25zLmxhbmczLlJhbmdlAAAAAAAAAAEC"
 +
+                
"AANMAApjb21wYXJhdG9ydAAWTGphdmEvdXRpbC9Db21wYXJhdG9yO0wAB21heGltdW10ABJMamF2YS9sYW5nL09iamVjdDtMAAdt"
 +
+                
"aW5pbXVtcQB+AAR4cH5yADNvcmcuYXBhY2hlLmNvbW1vbnMubGFuZzMuUmFuZ2UkQ29tcGFyYWJsZUNvbXBhcmF0b3IAAAAAAAAA"
 +
+                
"ABIAAHhyAA5qYXZhLmxhbmcuRW51bQAAAAAAAAAAEgAAeHB0AAhJTlNUQU5DRXNyABFqYXZhLmxhbmcuSW50ZWdlchLioKT3gYc4"
 +
+                
"AgABSQAFdmFsdWV4cgAQamF2YS5sYW5nLk51bWJlcoaslR0LlOCLAgAAeHAAAAACc3EAfgAKAAAAAQ==",
+            
"rO0ABXNyACJvcmcuYXBhY2hlLmNvbW1vbnMubGFuZzMuTG9uZ1JhbmdlAAAAAAAAAAECAAB4cgAkb3JnLmFwYWNoZS5jb21tb25z"
 +
+                
"LmxhbmczLk51bWJlclJhbmdlAAAAAAAAAAECAAB4cgAeb3JnLmFwYWNoZS5jb21tb25zLmxhbmczLlJhbmdlAAAAAAAAAAECAANM"
 +
+                
"AApjb21wYXJhdG9ydAAWTGphdmEvdXRpbC9Db21wYXJhdG9yO0wAB21heGltdW10ABJMamF2YS9sYW5nL09iamVjdDtMAAdtaW5p"
 +
+                
"bXVtcQB+AAR4cH5yADNvcmcuYXBhY2hlLmNvbW1vbnMubGFuZzMuUmFuZ2UkQ29tcGFyYWJsZUNvbXBhcmF0b3IAAAAAAAAAABIA"
 +
+                
"AHhyAA5qYXZhLmxhbmcuRW51bQAAAAAAAAAAEgAAeHB0AAhJTlNUQU5DRXNyAA5qYXZhLmxhbmcuTG9uZzuL5JDMjyPfAgABSgAF"
 +
+                
"dmFsdWV4cgAQamF2YS5sYW5nLk51bWJlcoaslR0LlOCLAgAAeHAAAAAAAAAAAnNxAH4ACgAAAAAAAAAB",
+            
"rO0ABXNyACRvcmcuYXBhY2hlLmNvbW1vbnMubGFuZzMuRG91YmxlUmFuZ2UAAAAAAAAAAQIAAHhyACRvcmcuYXBhY2hlLmNvbW1v"
 +
+                
"bnMubGFuZzMuTnVtYmVyUmFuZ2UAAAAAAAAAAQIAAHhyAB5vcmcuYXBhY2hlLmNvbW1vbnMubGFuZzMuUmFuZ2UAAAAAAAAAAQIA"
 +
+                
"A0wACmNvbXBhcmF0b3J0ABZMamF2YS91dGlsL0NvbXBhcmF0b3I7TAAHbWF4aW11bXQAEkxqYXZhL2xhbmcvT2JqZWN0O0wAB21p"
 +
+                
"bmltdW1xAH4ABHhwfnIAM29yZy5hcGFjaGUuY29tbW9ucy5sYW5nMy5SYW5nZSRDb21wYXJhYmxlQ29tcGFyYXRvcgAAAAAAAAAA"
 +
+                
"EgAAeHIADmphdmEubGFuZy5FbnVtAAAAAAAAAAASAAB4cHQACElOU1RBTkNFc3IAEGphdmEubGFuZy5Eb3VibGWAs8JKKWv7BAIA"
 +
+                
"AUQABXZhbHVleHIAEGphdmEubGFuZy5OdW1iZXKGrJUdC5TgiwIAAHhwQAAAAAAAAABzcQB+AAo/8AAAAAAAAA=="
+        };
+        for (int i = 0; i < streams.length; i++) {
+            final Range<?> actual = 
SerializationUtils.deserialize(Base64.getDecoder().decode(streams[i]));
+            assertEquals(expected[i].getClass(), actual.getClass());
+            assertEquals(expected[i], actual);
+            assertEquals(expected[i].hashCode(), actual.hashCode());
+        }
+    }
+
     @Test
     void testForgedReversedComparatorBreaksContains() throws Exception {
         final Range<Integer> reference = Range.of(Integer.valueOf(1), 
Integer.valueOf(10));
@@ -137,49 +203,43 @@ void testForgedReversedComparatorBreaksContains() throws 
Exception {
         assertTrue(reference.contains(Integer.valueOf(5)));
     }
 
-    /**
-     * Forged stream with {@code maximum == null}; symmetric to F-061b.
-     */
     @Test
     void testMaximumNullViaForgedStream() throws Exception {
         final Integer min = Integer.valueOf(1);
-        final int canonicalHash = Objects.hash(min, (Object) null);
         final Object comparator = Range.of(Integer.valueOf(1), 
Integer.valueOf(2)).getComparator();
-        final byte[] forged = forgeRangeStream(comparator, min, null, 
canonicalHash);
+        final byte[] forged = forgeRangeStream(comparator, min, null);
         assertThrows(InvalidObjectException.class, () -> deserialize(forged));
     }
 
-    /**
-     * Forged stream with {@code minimum == null}; {@code Objects.hash(null, 
max)} is a valid int, so the F-004 check passes. {@code contains()} NPEs
-     * because {@code comparator.compare(element, null)} unboxes null (or, for 
ComparableComparator, calls {@code element.compareTo(null)} which is an
-     * NPE-by-contract).
-     */
     @Test
     void testMinimumNullViaForgedStream() throws Exception {
         final Integer max = Integer.valueOf(10);
-        final int canonicalHash = Objects.hash((Object) null, max);
         // comparator must be non-null here so we isolate the minimum-null gap.
         // We use ComparableComparator.INSTANCE via deserialization round-trip 
of a real Range.
         final Object comparator = Range.of(Integer.valueOf(1), 
Integer.valueOf(2)).getComparator();
-        final byte[] forged = forgeRangeStream(comparator, null, max, 
canonicalHash);
+        final byte[] forged = forgeRangeStream(comparator, null, max);
         assertThrows(InvalidObjectException.class, () -> deserialize(forged));
     }
 
-    /**
-     * Forged stream with a NaN maximum and a canonically matching hashCode: 
the hashCode gate passes and the
-     * comparator ordering gate passes (NaN sorts above everything under 
Double.compareTo), so only the NaN
-     * endpoint gate stands between the stream and a half-unbounded fail-open 
range.
-     */
+
     @Test
     void testNaNEndpointViaForgedStream() throws Exception {
         final Double min = Double.valueOf(5.0);
         final Double max = Double.valueOf(Double.NaN);
-        final int canonicalHash = Objects.hash(min, max);
         final Object comparator = Range.of(Integer.valueOf(1), 
Integer.valueOf(2)).getComparator();
-        final byte[] forged = forgeRangeStream(comparator, min, max, 
canonicalHash);
+        final byte[] forged = forgeRangeStream(comparator, min, max);
         assertThrows(InvalidObjectException.class, () -> deserialize(forged));
     }
 
+    @Test
+    void testRoundTripChangingHashEndpoints() {
+        final Range<ChangingHashEndpoint> original = Range.of(new 
ChangingHashEndpoint(1), new ChangingHashEndpoint(2));
+        final Range<ChangingHashEndpoint> copy = 
SerializationUtils.roundtrip(original);
+        assertEquals(123, original.getMinimum().hashCode());
+        assertEquals(0, copy.getMinimum().hashCode());
+        assertEquals(Objects.hash(copy.getMinimum(), copy.getMaximum()), 
copy.hashCode());
+    }
+
     @Test
     void testRoundTripPreservesCorrectHashCode() throws Exception {
         final Range<String> range = Range.of("apple", "mango");

Reply via email to