From 983d0c74d00c1c933a872ccc4815408f00f6be8d Mon Sep 17 00:00:00 2001 From: Gary Gregory Date: Tue, 5 May 2026 19:32:12 +0000 Subject: [PATCH] A serialized Fraction can't store a bad cached hashCode. --- .../java/org/apache/commons/lang3/Range.java | 6 +- .../apache/commons/lang3/math/Fraction.java | 25 ++++++- .../commons/lang3/RangeReadObjectTest.java | 2 +- .../commons/lang3/SerializationUtilsTest.java | 9 ++- .../lang3/math/FractionReadObjectTest.java | 70 +++++++++++++++++++ 5 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 src/test/java/org/apache/commons/lang3/math/FractionReadObjectTest.java diff --git a/src/main/java/org/apache/commons/lang3/Range.java b/src/main/java/org/apache/commons/lang3/Range.java index 5200b4ee54b..2c1743956d7 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 b8808264018..b0a9476f42b 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. *

diff --git a/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java b/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java index 9976e94a10f..866270b596f 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 6fe4d24ce6c..0702f41f128 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 extends Supplier, 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 00000000000..ecbc944c612 --- /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 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); + + } +}