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 eb560776a A serialized Fraction can't store a bad cached hashCode. 
(#1634)
eb560776a is described below

commit eb560776a3430a600dacce67239e69420a607501
Author: Gary Gregory <[email protected]>
AuthorDate: Tue May 5 17:45:39 2026 -0400

    A serialized Fraction can't store a bad cached hashCode. (#1634)
---
 src/main/java/org/apache/commons/lang3/Range.java  |  6 +-
 .../org/apache/commons/lang3/math/Fraction.java    | 25 +++++++-
 .../apache/commons/lang3/RangeReadObjectTest.java  |  2 +-
 .../commons/lang3/SerializationUtilsTest.java      |  9 ++-
 .../commons/lang3/math/FractionReadObjectTest.java | 70 ++++++++++++++++++++++
 5 files changed, 105 insertions(+), 7 deletions(-)

diff --git a/src/main/java/org/apache/commons/lang3/Range.java 
b/src/main/java/org/apache/commons/lang3/Range.java
index 5200b4ee5..2c1743956 100644
--- a/src/main/java/org/apache/commons/lang3/Range.java
+++ b/src/main/java/org/apache/commons/lang3/Range.java
@@ -535,11 +535,13 @@ public boolean isStartedBy(final T element) {
     }
 
     /**
-     * See {@link Serializable}.
+     * 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.
      *
      * @param in See {@link Serializable}.
-     * @throws IOException See {@link Serializable}.
+     * @throws IOException            See {@link Serializable}.
      * @throws ClassNotFoundException See {@link Serializable}.
+     * @throws InvalidObjectException If the hashCode doesn't match the 
minimum and maximum.
      */
     private void readObject(final ObjectInputStream in) throws IOException, 
ClassNotFoundException {
         in.defaultReadObject();
diff --git a/src/main/java/org/apache/commons/lang3/math/Fraction.java 
b/src/main/java/org/apache/commons/lang3/math/Fraction.java
index b88082640..b0a9476f4 100644
--- a/src/main/java/org/apache/commons/lang3/math/Fraction.java
+++ b/src/main/java/org/apache/commons/lang3/math/Fraction.java
@@ -16,6 +16,9 @@
  */
 package org.apache.commons.lang3.math;
 
+import java.io.IOException;
+import java.io.InvalidObjectException;
+import java.io.ObjectInputStream;
 import java.io.Serializable;
 import java.math.BigInteger;
 import java.util.Objects;
@@ -402,6 +405,10 @@ private static int greatestCommonDivisor(int u, int v) {
         return -u * (1 << k); // gcd is u*2^k
     }
 
+    private static int hash(final int value1, final int value2) {
+        return Objects.hash(value1, value2);
+    }
+
     /**
      * Multiplies two integers, checking for overflow.
      *
@@ -489,7 +496,7 @@ private static int subAndCheck(final int x, final int y) {
     private Fraction(final int numerator, final int denominator) {
         this.numerator = numerator;
         this.denominator = denominator;
-        this.hashCode = Objects.hash(denominator, numerator);
+        this.hashCode = hash(denominator, numerator);
     }
 
     /**
@@ -837,6 +844,22 @@ public Fraction pow(final int power) {
         return f.pow(power / 2).multiplyBy(this);
     }
 
+    /**
+     * Validates the cached hashCode after deserialization. Throws a {@link 
InvalidObjectException} when the stored hashCode does not match the canonical 
hash
+     * of the deserialized numerator/denominator.
+     *
+     * @param in See {@link Serializable}.
+     * @throws IOException            See {@link Serializable}.
+     * @throws ClassNotFoundException See {@link Serializable}.
+     * @throws InvalidObjectException If the hashCode doesn't match the 
denominator and numerator.
+     */
+    private void readObject(final ObjectInputStream in) throws IOException, 
ClassNotFoundException {
+        in.defaultReadObject();
+        if (hashCode != hash(denominator, numerator)) {
+            throw new InvalidObjectException("Fraction hashCode does not match 
numerator/denominator.");
+        }
+    }
+
     /**
      * Reduce the fraction to the smallest values for the numerator and 
denominator, returning the result.
      * <p>
diff --git a/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java 
b/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
index 9976e94a1..866270b59 100644
--- a/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
+++ b/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
@@ -27,7 +27,7 @@
 import org.junit.jupiter.api.Test;
 
 /**
- * Tests that a serialized Range can't store a bad cached hashCode.
+ * Tests that a serialized {@link Range} can't store a bad cached hashCode.
  */
 class RangeReadObjectTest {
 
diff --git a/src/test/java/org/apache/commons/lang3/SerializationUtilsTest.java 
b/src/test/java/org/apache/commons/lang3/SerializationUtilsTest.java
index 6fe4d24ce..0702f41f1 100644
--- a/src/test/java/org/apache/commons/lang3/SerializationUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/SerializationUtilsTest.java
@@ -61,15 +61,17 @@ interface SerializableSupplier<T> extends Supplier<T>, 
Serializable {
 /**
  * Tests {@link SerializationUtils}.
  */
-class SerializationUtilsTest extends AbstractLangTest {
+public class SerializationUtilsTest extends AbstractLangTest {
 
     static final String CLASS_NOT_FOUND_MESSAGE = 
"ClassNotFoundSerialization.readObject fake exception";
+
     protected static final String SERIALIZE_IO_EXCEPTION_MESSAGE = "Anonymous 
OutputStream I/O exception";
 
-    static byte[] intToBytes(final int v) {
+    public static byte[] intToBytes(final int v) {
         return new byte[] { (byte) (v >>> 24), (byte) (v >>> 16), (byte) (v 
>>> 8), (byte) v };
     }
-    static byte[] replaceLastInt(final byte[] src, final int from, final int 
to) {
+
+    public static byte[] replaceLastInt(final byte[] src, final int from, 
final int to) {
         final byte[] fromB = intToBytes(from);
         final byte[] toB = intToBytes(to);
         final byte[] out = src.clone();
@@ -85,6 +87,7 @@ static byte[] replaceLastInt(final byte[] src, final int 
from, final int to) {
         fail("No legitimate int in stream, serialization must keep hashCode in 
default field set");
         return null;
     }
+
     private String iString;
 
     private Integer iInteger;
diff --git 
a/src/test/java/org/apache/commons/lang3/math/FractionReadObjectTest.java 
b/src/test/java/org/apache/commons/lang3/math/FractionReadObjectTest.java
new file mode 100644
index 000000000..ecbc944c6
--- /dev/null
+++ b/src/test/java/org/apache/commons/lang3/math/FractionReadObjectTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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
+ *
+ *      https://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.commons.lang3.math;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.InvalidObjectException;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.lang3.SerializationException;
+import org.apache.commons.lang3.SerializationUtils;
+import org.apache.commons.lang3.SerializationUtilsTest;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests that a serialized {@link Fraction} can't store a bad cached hashCode.
+ */
+public class FractionReadObjectTest {
+
+    @Test
+    public void testBadHashCodeStreamIsRejected() throws Exception {
+        final Fraction fraction = Fraction.getFraction(3, 7);
+        final byte[] bytes = SerializationUtils.serialize(fraction);
+        final int hashCode = (Integer) FieldUtils.readDeclaredField(fraction, 
"hashCode", true);
+        final byte[] edited = SerializationUtilsTest.replaceLastInt(bytes, 
hashCode, 0xCAFEBABE);
+        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: Fraction hashCode does 
not match numerator/denominator.", ex.getMessage());
+
+    }
+
+    @Test
+    public void testHashMapLookupAfterRoundTrip() throws Exception {
+        final Fraction fraction = Fraction.getFraction(1, 4);
+        final byte[] bytes = SerializationUtils.serialize(fraction);
+        final Fraction deserialized = SerializationUtils.deserialize(bytes);
+        final Map<Fraction, String> map = new HashMap<>();
+        map.put(fraction, "quarter");
+        assertEquals("quarter", map.get(deserialized), "HashMap lookup must 
work after deserialization");
+    }
+
+    @Test
+    public void testRoundTripPreservesHashCode() throws Exception {
+        final Fraction fraction = Fraction.getFraction(1, 4);
+        final Fraction roundtrip = SerializationUtils.roundtrip(fraction);
+        assertEquals(fraction.hashCode(), roundtrip.hashCode(), "Round-trip 
serialization must preserve the correct hashCode");
+        assertEquals(fraction, roundtrip);
+
+    }
+}

Reply via email to