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

roryqi pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 4d7f636683 [Cherry-pick to branch-1.3] [#12598] fix(common): Reject 
out-of-range numeric statistic values (#12599) (#12874)
4d7f636683 is described below

commit 4d7f636683d6d56980597135a60040e135006347
Author: roryqi <[email protected]>
AuthorDate: Thu Sep 3 17:32:12 2026 +0800

    [Cherry-pick to branch-1.3] [#12598] fix(common): Reject out-of-range 
numeric statistic values (#12599) (#12874)
    
    ### What changes were proposed in this pull request?
    
    Cherry-pick commit `09e68277b2d8df18b397c2373a2ada2aef8ce04f` from
    #12599 to `branch-1.3`.
    
    This change:
    
    - Rejects integral statistic values that cannot be represented as a
    signed 64-bit integer.
    - Rejects floating-point statistic values that parse to a non-finite
    double.
    - Adds tests for boundaries, nested values, and statistics update
    request deserialization.
    
    ### Why are the changes needed?
    
    Out-of-range numeric statistic values are silently converted and stored
    as different values in 1.3. For example, an integer larger than
    `Long.MAX_VALUE` wraps to a negative value, while an oversized
    floating-point value becomes `Infinity`.
    
    This backport prevents silent statistic corruption by rejecting these
    values.
    
    Backport: #12599
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Out-of-range numeric statistic values now result in a `400 Bad
    Request` instead of returning success and storing an altered value.
    
    No API signatures, properties, or stored-data formats are changed.
    
    ### How was this patch tested?
    
    ```shell
    ./gradlew :common:test --tests org.apache.gravitino.json.TestJsonUtils 
:common:javadoc :common:spotlessCheck -PskipITs
    ```
    
    Co-authored-by: YangJie <[email protected]>
---
 .../java/org/apache/gravitino/json/JsonUtils.java  | 38 ++++-----
 .../org/apache/gravitino/json/TestJsonUtils.java   | 93 ++++++++++++++++++++++
 2 files changed, 112 insertions(+), 19 deletions(-)

diff --git a/common/src/main/java/org/apache/gravitino/json/JsonUtils.java 
b/common/src/main/java/org/apache/gravitino/json/JsonUtils.java
index 2b664acc36..59a4b5f7ea 100644
--- a/common/src/main/java/org/apache/gravitino/json/JsonUtils.java
+++ b/common/src/main/java/org/apache/gravitino/json/JsonUtils.java
@@ -49,7 +49,6 @@ import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import java.io.IOException;
-import java.io.UnsupportedEncodingException;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -1367,13 +1366,27 @@ public class JsonUtils {
     }
   }
 
-  private static StatisticValue<?> getStatisticValue(JsonNode node) throws 
IOException {
+  private static StatisticValue<?> getStatisticValue(JsonNode node) {
     Preconditions.checkArgument(
         node != null && !node.isNull(), "Cannot parse statistic value from 
invalid JSON: %s", node);
     if (node.isIntegralNumber()) {
+      // BigInteger nodes are integral as well, and asLong() would wrap them 
around silently.
+      Preconditions.checkArgument(
+          node.canConvertToLong(),
+          "Statistic value is out of the range of a 64-bit signed integer: %s",
+          node);
       return StatisticValues.longValue(node.asLong());
     } else if (node.isFloatingPointNumber()) {
-      return StatisticValues.doubleValue(node.asDouble());
+      // Jackson parses a literal past the double range into a DoubleNode that 
already holds
+      // infinity (USE_BIG_DECIMAL_FOR_FLOATS is off), and the serializer 
would write that back
+      // out as the JSON string "Infinity", so the value would come back as a 
string.
+      double doubleValue = node.asDouble();
+      Preconditions.checkArgument(
+          Double.isFinite(doubleValue),
+          "Statistic value is out of the range of a 64-bit floating point 
number, the literal"
+              + " parsed to %s",
+          doubleValue);
+      return StatisticValues.doubleValue(doubleValue);
     } else if (node.isTextual()) {
       return StatisticValues.stringValue(node.asText());
     } else if (node.isBoolean()) {
@@ -1382,10 +1395,7 @@ public class JsonUtils {
       ArrayNode arrayNode = (ArrayNode) node;
       List<StatisticValue<Object>> values = 
Lists.newArrayListWithCapacity(arrayNode.size());
       for (JsonNode arrayElement : arrayNode) {
-        StatisticValue<?> value = getStatisticValue(arrayElement);
-        if (value != null) {
-          values.add((StatisticValue<Object>) value);
-        }
+        values.add((StatisticValue<Object>) getStatisticValue(arrayElement));
       }
       return StatisticValues.listValue(values);
     } else if (node.isObject()) {
@@ -1393,20 +1403,10 @@ public class JsonUtils {
       Map<String, StatisticValue<?>> map = Maps.newHashMap();
       objectNode
           .fields()
-          .forEachRemaining(
-              entry -> {
-                try {
-                  StatisticValue<?> value = 
getStatisticValue(entry.getValue());
-                  if (value != null) {
-                    map.put(entry.getKey(), value);
-                  }
-                } catch (IOException e) {
-                  throw new RuntimeException(e);
-                }
-              });
+          .forEachRemaining(entry -> map.put(entry.getKey(), 
getStatisticValue(entry.getValue())));
       return StatisticValues.objectValue(map);
     } else {
-      throw new UnsupportedEncodingException(
+      throw new IllegalArgumentException(
           String.format("Don't support json node type %s", 
node.getNodeType()));
     }
   }
diff --git a/common/src/test/java/org/apache/gravitino/json/TestJsonUtils.java 
b/common/src/test/java/org/apache/gravitino/json/TestJsonUtils.java
index c99af454e3..5d29dd33be 100644
--- a/common/src/test/java/org/apache/gravitino/json/TestJsonUtils.java
+++ b/common/src/test/java/org/apache/gravitino/json/TestJsonUtils.java
@@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonMappingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.module.SimpleModule;
@@ -36,6 +37,7 @@ import 
org.apache.gravitino.dto.rel.partitions.IdentityPartitionDTO;
 import org.apache.gravitino.dto.rel.partitions.ListPartitionDTO;
 import org.apache.gravitino.dto.rel.partitions.PartitionDTO;
 import org.apache.gravitino.dto.rel.partitions.RangePartitionDTO;
+import org.apache.gravitino.dto.requests.StatisticsUpdateRequest;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.rel.types.Type;
 import org.apache.gravitino.rel.types.Types;
@@ -47,6 +49,11 @@ import org.junit.jupiter.api.Test;
 
 public class TestJsonUtils {
 
+  private static final String INTEGRAL_RANGE_MESSAGE =
+      "out of the range of a 64-bit signed integer";
+  private static final String FLOATING_RANGE_MESSAGE =
+      "out of the range of a 64-bit floating point number";
+
   private static ObjectMapper objectMapper;
 
   @BeforeAll
@@ -520,4 +527,90 @@ public class TestJsonUtils {
         objectMapper.readValue(expectJson, StatisticValue.class),
         objectMapper.readValue(objectValue, StatisticValue.class));
   }
+
+  @Test
+  void testStatisticValueRejectsOutOfRangeIntegral() throws 
JsonProcessingException {
+    // The 64-bit boundaries themselves must still be accepted.
+    Assertions.assertEquals(
+        StatisticValues.longValue(Long.MAX_VALUE),
+        objectMapper.readValue("9223372036854775807", StatisticValue.class));
+    Assertions.assertEquals(
+        StatisticValues.longValue(Long.MIN_VALUE),
+        objectMapper.readValue("-9223372036854775808", StatisticValue.class));
+
+    // Anything past a boundary has no lossless long representation and must 
be rejected rather
+    // than wrapped around.
+    assertRejected("9223372036854775808", INTEGRAL_RANGE_MESSAGE);
+    assertRejected("-9223372036854775809", INTEGRAL_RANGE_MESSAGE);
+    assertRejected("123456789012345678901234567890", INTEGRAL_RANGE_MESSAGE);
+    assertRejected("-123456789012345678901234567890", INTEGRAL_RANGE_MESSAGE);
+
+    // The deserializer reads the whole tree and recurses in plain Java, so an 
element nested in a
+    // list or an object is rejected at the same level as a scalar. This 
covers those two branches,
+    // not Jackson's own nesting - see 
testStatisticsUpdateRequestRejectsOutOfRangeValue for that.
+    assertRejected("[1,123456789012345678901234567890]", 
INTEGRAL_RANGE_MESSAGE);
+    assertRejected("{\"key\":123456789012345678901234567890}", 
INTEGRAL_RANGE_MESSAGE);
+  }
+
+  @Test
+  void testStatisticValueRejectsNonFiniteFloatingPoint() {
+    // A magnitude beyond the double range is only representable as an 
infinity, which the
+    // serializer writes back out as the JSON string "Infinity" - the value 
would come back as a
+    // string on the next round trip. The message reports the parsed double 
rather than echoing the
+    // literal, because the node Jackson hands us already holds the infinity; 
assert it in full so
+    // that stays visible.
+    assertRejected(
+        "1.5E400", FLOATING_RANGE_MESSAGE + ", the literal parsed to " + 
Double.POSITIVE_INFINITY);
+    assertRejected(
+        "-1.5E400", FLOATING_RANGE_MESSAGE + ", the literal parsed to " + 
Double.NEGATIVE_INFINITY);
+  }
+
+  @Test
+  void testStatisticsUpdateRequestRejectsOutOfRangeValue() {
+    // The shape the REST layer actually deserializes: the value is Map 
content, so Jackson wraps
+    // the rejection into a JsonMappingException, which the server maps to 
400. Use a bare mapper
+    // rather than the shared one, because setUp registers a StatisticValue 
deserializer onto that
+    // singleton and the assertion would then hold even without the DTO's 
@JsonDeserialize
+    // annotation. The server's ObjectMapperProvider registers no such module, 
so the annotation is
+    // what has to carry the deserializer here.
+    ObjectMapper mapper = new ObjectMapper();
+    JsonMappingException e =
+        Assertions.assertThrows(
+            JsonMappingException.class,
+            () ->
+                mapper.readValue(
+                    "{\"updates\":{\"rowCount\":9223372036854775808}}",
+                    StatisticsUpdateRequest.class));
+
+    Assertions.assertInstanceOf(IllegalArgumentException.class, e.getCause());
+    
Assertions.assertTrue(e.getCause().getMessage().contains(INTEGRAL_RANGE_MESSAGE));
+  }
+
+  @Test
+  void testStatisticValueRejectsUnsupportedNodeType() {
+    // A BINARY node cannot come from JSON text, but convertValue reaches the 
terminal branch
+    // through an embedded-object token. Note that convertValue itself wraps 
any IOException the
+    // deserializer throws into an IllegalArgumentException carrying the same 
message, so the cause
+    // is what distinguishes our own rejection from a laundered checked 
exception.
+    IllegalArgumentException e =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> objectMapper.convertValue(new byte[] {1, 2}, 
StatisticValue.class));
+
+    Assertions.assertTrue(
+        e.getMessage().contains("Don't support json node type BINARY"),
+        () -> "Unexpected rejection reason: " + e.getMessage());
+    Assertions.assertNull(e.getCause());
+  }
+
+  private static void assertRejected(String json, String expectedMessagePart) {
+    IllegalArgumentException e =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> objectMapper.readValue(json, StatisticValue.class));
+
+    Assertions.assertTrue(
+        e.getMessage().contains(expectedMessagePart),
+        () -> "Unexpected rejection reason: " + e.getMessage());
+  }
 }

Reply via email to