From e1cfcc290bf17a0dd16f3b3c4cd74b64a1f82d4e Mon Sep 17 00:00:00 2001 From: Simon Bernard Date: Fri, 24 Jan 2025 18:10:19 +0100 Subject: [PATCH] Ensure there is no precision loss on Number conversion --- .../leshan/core/util/datatype/NumberUtil.java | 109 ++++++++++++++++-- .../leshan/core/datatype/NumberUtilTest.java | 74 ++++++++++++ 2 files changed, 171 insertions(+), 12 deletions(-) diff --git a/leshan-lwm2m-core/src/main/java/org/eclipse/leshan/core/util/datatype/NumberUtil.java b/leshan-lwm2m-core/src/main/java/org/eclipse/leshan/core/util/datatype/NumberUtil.java index e2256695ca..44a2c2cbe8 100644 --- a/leshan-lwm2m-core/src/main/java/org/eclipse/leshan/core/util/datatype/NumberUtil.java +++ b/leshan-lwm2m-core/src/main/java/org/eclipse/leshan/core/util/datatype/NumberUtil.java @@ -22,6 +22,20 @@ public class NumberUtil { + /** + * Minimal Long that can be safely in double (without precision loss) + * + * Because Double precision floating point format only has 52 bits to represent the mantissa + */ + private static final long MIN_SAFE_DOUBLE_INTEGER = -(1L << 53) + 1; + + /** + * Maximum Long that can be safely in double (without precision loss) + * + * Because Double precision floating point format only has 52 bits to represent the mantissa + */ + private static final long MAX_SAFE_DOUBLE_INTEGER = (1L << 53) - 1; + /** * Convert the given number to long without loss allowing Floating-point number conversion * @@ -46,7 +60,10 @@ public static Long numberToLong(Number number) throws IllegalArgumentException { */ public static Long numberToLong(Number number, boolean permissiveNumberConversion) throws IllegalArgumentException { // handle INTEGER - if (number instanceof Byte || number instanceof Short || number instanceof Integer || number instanceof Long) { + if (number instanceof Long) { + return (Long) number; + } + if (number instanceof Byte || number instanceof Short || number instanceof Integer) { return number.longValue(); } if (number instanceof BigInteger) { @@ -54,7 +71,8 @@ public static Long numberToLong(Number number, boolean permissiveNumberConversio BigInteger bigInt = (BigInteger) number; if (bigInt.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0 || bigInt.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0) { - throw new IllegalArgumentException(String.format("%s : can not be store in a long", bigInt)); + throw new IllegalArgumentException( + String.format("BigInteger %s : can not be store in a long", bigInt)); } return bigInt.longValue(); } @@ -71,7 +89,8 @@ public static Long numberToLong(Number number, boolean permissiveNumberConversio try { return bigDec.longValueExact(); } catch (ArithmeticException e) { - throw new IllegalArgumentException(String.format("%s : can not be store in a long", bigDec)); + throw new IllegalArgumentException( + String.format("BigDecimal %s : can not be store in a long", bigDec)); } } } @@ -81,7 +100,7 @@ public static Long numberToLong(Number number, boolean permissiveNumberConversio long longValue = number.longValue(); // if long value is negative this means that this is a too long unsigned long if (longValue < 0) { - throw new IllegalArgumentException(String.format("%s : can not be store in a long", number)); + throw new IllegalArgumentException(String.format("ULong %s : can not be store in a long", number)); } return longValue; } @@ -126,7 +145,8 @@ public static ULong numberToULong(Number number, boolean permissiveNumberConvers // check big integer is not too big for a long BigInteger bigInt = (BigInteger) number; if (bigInt.signum() == -1 || bigInt.compareTo(ULong.MAX_VALUE) > 0) { - throw new IllegalArgumentException(String.format("%s : can not be store in an unsigned long", bigInt)); + throw new IllegalArgumentException( + String.format("BigInteger %s : can not be store in an unsigned long", bigInt)); } return ULong.valueOf(bigInt); } @@ -141,18 +161,19 @@ public static ULong numberToULong(Number number, boolean permissiveNumberConvers } if (bigDec != null) { if (bigDec.signum() == -1) { - throw new IllegalArgumentException( - String.format("%s : can not be store in an unsigned long", bigDec)); + throw new IllegalArgumentException(String + .format("BigDecimal %s : can not convert negative number to an unsigned long", bigDec)); } else { try { BigInteger bigInt = bigDec.toBigIntegerExact(); if (bigInt.compareTo(ULong.MAX_VALUE) > 0) { throw new IllegalArgumentException( - String.format("%s : can not be store in an unsigned long", bigInt)); + String.format("BigDecimal %s : can not be store in an unsigned long", bigInt)); } return ULong.valueOf(bigInt); } catch (ArithmeticException e) { - throw new IllegalArgumentException(String.format("%s : can not be store in a long", bigDec)); + throw new IllegalArgumentException( + String.format("BigDecimal %s : can not be store in a long", bigDec)); } } } @@ -185,12 +206,76 @@ public static EInteger unsignedLongToEInteger(long v) { * @throws IllegalArgumentException if the number can not be store in a long. */ public static Double numberToDouble(Number number, boolean permissiveNumberConversion) { - if (permissiveNumberConversion) - return number.doubleValue(); + if (permissiveNumberConversion) { + if (number instanceof Byte || number instanceof Short || number instanceof Integer) { + return number.doubleValue(); + } + if (number instanceof Long) { + // check if long can be safely converted + if (MIN_SAFE_DOUBLE_INTEGER <= (Long) number && (Long) number <= MAX_SAFE_DOUBLE_INTEGER) { + // this is safe zone where all integer can be store without precision loss in double + return number.doubleValue(); + } else { + // Convert long to double + double convertedDouble = number.doubleValue(); + + // Check if the double accurately represents the long + if ((long) convertedDouble != number.longValue()) { + throw new IllegalArgumentException( + String.format("Can not convert Long %s to double safely", number.toString())); + } + return convertedDouble; + } + + } + if (number instanceof BigInteger) { + // check if big integer can be safely converted + BigInteger bigInt = (BigInteger) number; + if (bigInt.compareTo(BigInteger.valueOf(MIN_SAFE_DOUBLE_INTEGER)) >= 0 + && bigInt.compareTo(BigInteger.valueOf(MAX_SAFE_DOUBLE_INTEGER)) <= 0) { + // this is safe zone where all integer can be store without precision loss in double + return number.doubleValue(); + } else { + // Convert BigInteger to double + double convertedDouble = number.doubleValue(); - if (number instanceof Float || number instanceof Double || number instanceof BigDecimal) + // Convert the double back to BigInteger exact representation + try { + BigInteger reconstructedValue = new BigDecimal(convertedDouble).toBigIntegerExact(); + if (!number.equals(reconstructedValue)) { + throw new IllegalArgumentException( + String.format("Can not convert BigInteger %s to double safely", bigInt)); + } + } catch (ArithmeticException e) { + throw new IllegalArgumentException( + String.format("Can not convert BigInteger %s to double safely", number)); + } + return convertedDouble; + } + } + } + + if (number instanceof Double) { + return (Double) number; + } + if (number instanceof Float) { return number.doubleValue(); + } + if (number instanceof BigDecimal) { + // We can not really ensure that BigDecimal fit safely in a double. + // Even for very simple decimal number, the internal numeric value could differ. + // Eg. : 0.1 Bigdecimal value can not exactly fit in double but it will be encoded as + // 0.099999999999999... + // (see https://observablehq.com/@benaubin/floating-point) + // So the best we can do is to be sure big decimal is not out of range. + Double result = number.doubleValue(); + if (result == Double.POSITIVE_INFINITY || result == Double.NEGATIVE_INFINITY) { + throw new IllegalArgumentException( + String.format("Can not convert Bigdecimal %s to double safely (out of range)", number)); + } + return result; + } throw new IllegalArgumentException(String.format("Floating-point number expected but was %s (%s)", number, number.getClass().getCanonicalName())); } diff --git a/leshan-lwm2m-core/src/test/java/org/eclipse/leshan/core/datatype/NumberUtilTest.java b/leshan-lwm2m-core/src/test/java/org/eclipse/leshan/core/datatype/NumberUtilTest.java index 1c5d0812ce..3192a7547d 100644 --- a/leshan-lwm2m-core/src/test/java/org/eclipse/leshan/core/datatype/NumberUtilTest.java +++ b/leshan-lwm2m-core/src/test/java/org/eclipse/leshan/core/datatype/NumberUtilTest.java @@ -16,6 +16,7 @@ package org.eclipse.leshan.core.datatype; import static org.eclipse.leshan.core.util.datatype.NumberUtil.longToInt; +import static org.eclipse.leshan.core.util.datatype.NumberUtil.numberToDouble; import static org.eclipse.leshan.core.util.datatype.NumberUtil.numberToLong; import static org.eclipse.leshan.core.util.datatype.NumberUtil.numberToULong; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -272,4 +273,77 @@ public void too_long_to_int() { longToInt(2147483648l); }); } + + @Test + public void convert_number_to_double() { + assertEquals(-128d, numberToDouble(Byte.valueOf("-128"), true)); + assertEquals(127d, numberToDouble(Byte.valueOf("127"), true)); + + assertEquals(-32768d, numberToDouble(Short.valueOf("-32768"), true)); + assertEquals(32767d, numberToDouble(Short.valueOf("32767"), true)); + + assertEquals(-2147483648d, numberToDouble(Integer.valueOf("-2147483648"), true)); + assertEquals(2147483647d, numberToDouble(Integer.valueOf("2147483647"), true)); + + // safe limit + assertEquals(-9007199254740991d, numberToDouble(Long.valueOf("-9007199254740991"), true)); + assertEquals(9007199254740991d, numberToDouble(Long.valueOf("9007199254740991"), true)); + // outside safe limit + // see for more details : https://observablehq.com/@benaubin/floating-point + assertEquals(9007199254740992d, numberToDouble(Long.valueOf("9007199254740992"), true)); + assertEquals(9007199254740992d, numberToDouble(Long.valueOf("9007199254740992"), true)); + assertEquals(9007199254740994d, numberToDouble(Long.valueOf("9007199254740994"), true)); + + // safe limit + assertEquals(-9007199254740991d, numberToDouble(new BigInteger("-9007199254740991"), true)); + assertEquals(9007199254740991d, numberToDouble(new BigInteger("9007199254740991"), true)); + // outside safe limit + // see for more details : https://observablehq.com/@benaubin/floating-point + assertEquals(-9007199254740992d, numberToDouble(new BigInteger("-9007199254740992"), true)); + assertEquals(9007199254740992d, numberToDouble(new BigInteger("9007199254740992"), true)); + assertEquals(9007199254740994d, numberToDouble(new BigInteger("9007199254740994"), true)); + + // floating point + assertEquals(-340282346638528859811704183484516925440d, numberToDouble(new Float(-Float.MAX_VALUE), true)); + assertEquals(340282346638528859811704183484516925440d, numberToDouble(new Float(Float.MAX_VALUE), true)); + + assertEquals( + -179769313486231570814527423731704356798070567525844996598917476803157260780028538760589558632766878171540458953514382464234321326889464182768467546703537516986049910576551282076245490090389328944075868508455133942304583236903222948165808559332123348274797826204144723168738177180919299881250404026184124858368d, + numberToDouble(new Double(-Double.MAX_VALUE), true)); + assertEquals( + 179769313486231570814527423731704356798070567525844996598917476803157260780028538760589558632766878171540458953514382464234321326889464182768467546703537516986049910576551282076245490090389328944075868508455133942304583236903222948165808559332123348274797826204144723168738177180919299881250404026184124858368d, + numberToDouble(new Double(Double.MAX_VALUE), true)); + + assertEquals( + -179769313486231570814527423731704356798070567525844996598917476803157260780028538760589558632766878171540458953514382464234321326889464182768467546703537516986049910576551282076245490090389328944075868508455133942304583236903222948165808559332123348274797826204144723168738177180919299881250404026184124858368d, + numberToDouble(new BigDecimal(-Double.MAX_VALUE), true)); + assertEquals( + 179769313486231570814527423731704356798070567525844996598917476803157260780028538760589558632766878171540458953514382464234321326889464182768467546703537516986049910576551282076245490090389328944075868508455133942304583236903222948165808559332123348274797826204144723168738177180919299881250404026184124858368d, + numberToDouble(new BigDecimal(Double.MAX_VALUE), true)); + } + + @Test + public void long_does_not_fit_in_for_double() { + assertThrowsExactly(IllegalArgumentException.class, () -> { + numberToDouble(Long.valueOf("9007199254740993"), true); + }); + } + + @Test + public void biginteger_does_not_fit_in_double() { + assertThrowsExactly(IllegalArgumentException.class, () -> { + numberToDouble(new BigInteger("-9007199254740993"), true); + }); + } + + @Test + public void bigdecimal_does_not_fit_in_for_double() { + assertThrowsExactly(IllegalArgumentException.class, () -> { + numberToDouble(new BigDecimal(Double.MAX_VALUE).add(new BigDecimal(Double.MAX_VALUE)), true); + }); + + assertThrowsExactly(IllegalArgumentException.class, () -> { + numberToDouble(new BigDecimal(-Double.MAX_VALUE).add(new BigDecimal(-Double.MAX_VALUE)), true); + }); + } }