Require exact integer values for sliders

Amolith created

Check integer range bounds, current values, and options as XML integer
text before parsing them as floats. Also require each parsed value to
round-trip to the same integer and reject no-option unit-step ranges
outside float's consecutive exact integer window.

Change summary

src/main/java/eu/siacs/conversations/entities/Conversation.java     | 22 
src/test/java/eu/siacs/conversations/entities/ConversationTest.java | 21 
2 files changed, 40 insertions(+), 3 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/entities/Conversation.java 🔗

@@ -1878,14 +1878,14 @@ public class Conversation extends AbstractEntity
         final String minValue = range == null ? null : range.getAttribute("min");
         final String maxValue = range == null ? null : range.getAttribute("max");
         final boolean integerDatatype = isIntegerDatatype(datatype);
-        if (integerDatatype && (!isIntegerLexicalValue(minValue) || !isIntegerLexicalValue(maxValue))) return null;
+        if (integerDatatype && (!isExactIntegerSliderValue(minValue) || !isExactIntegerSliderValue(maxValue))) return null;
         final Float min = parseFloat(minValue);
         final Float max = parseFloat(maxValue);
         if (min == null || max == null || min >= max) return null;
 
         final String value = firstValue(field);
         if (value == null || value.equals("")) return null;
-        if (integerDatatype && !isIntegerLexicalValue(value)) return null;
+        if (integerDatatype && !isExactIntegerSliderValue(value)) return null;
         final Float parsedValue = parseFloat(value);
         if (parsedValue == null || parsedValue < min || parsedValue > max) return null;
 
@@ -1894,6 +1894,7 @@ public class Conversation extends AbstractEntity
 
         final Float step;
         if (options.size() < 2) {
+            if (integerDatatype && !isExactIntegerUnitRange(minValue, maxValue)) return null;
             step = integerDatatype ? 1f : 0f;
         } else {
             step = uniformStep(options);
@@ -1932,7 +1933,7 @@ public class Conversation extends AbstractEntity
         final List<Float> values = new ArrayList<>();
         for (final Option option : Option.forField(field)) {
             final String optionValue = option.getValue();
-            if (integerDatatype && !isIntegerLexicalValue(optionValue)) return null;
+            if (integerDatatype && !isExactIntegerSliderValue(optionValue)) return null;
             final Float value = parseFloat(optionValue);
             if (value == null) return null;
             values.add(value);
@@ -1968,6 +1969,21 @@ public class Conversation extends AbstractEntity
         return Math.abs(Math.rint(multiple) - multiple) < 0.0001f;
     }
 
+    private static boolean isExactIntegerSliderValue(final String value) {
+        if (!isIntegerLexicalValue(value)) return false;
+        final Float parsed = parseFloat(value);
+        return parsed != null
+                && new BigDecimal(Float.toString(parsed)).compareTo(new BigDecimal(value)) == 0;
+    }
+
+    private static boolean isExactIntegerUnitRange(final String minValue, final String maxValue) {
+        final BigDecimal min = new BigDecimal(minValue);
+        final BigDecimal max = new BigDecimal(maxValue);
+        final BigDecimal maxConsecutiveInteger = new BigDecimal("16777216");
+        return min.compareTo(maxConsecutiveInteger.negate()) >= 0
+                && max.compareTo(maxConsecutiveInteger) <= 0;
+    }
+
     private static boolean isIntegerLexicalValue(final String value) {
         if (value == null || value.equals("")) return false;
         final int start = value.charAt(0) == '+' || value.charAt(0) == '-' ? 1 : 0;

src/test/java/eu/siacs/conversations/entities/ConversationTest.java 🔗

@@ -241,6 +241,27 @@ public class ConversationTest {
         Assert.assertNull(Conversation.steppedSliderStep(field));
     }
 
+    @Test
+    public void steppedSliderStepRejectsFractionalIntegerValueAtFloatPrecisionBoundary() {
+        final var field = sliderField("xs:integer", "16777216", "16777218", "16777216.5");
+
+        Assert.assertNull(Conversation.steppedSliderStep(field));
+    }
+
+    @Test
+    public void steppedSliderStepRejectsIntegerValueRoundedByFloatParsing() {
+        final var field = sliderField("xs:integer", "16777216", "16777218", "16777217");
+
+        Assert.assertNull(Conversation.steppedSliderStep(field));
+    }
+
+    @Test
+    public void steppedSliderStepRejectsIntegerRangeWithUnrepresentableIntermediateValue() {
+        final var field = sliderField("xs:integer", "16777216", "16777218", "16777216");
+
+        Assert.assertNull(Conversation.steppedSliderStep(field));
+    }
+
     @Test
     public void formatSliderValueDoesNotClampLargeIntegerDatatypesToInt() {
         Assert.assertEquals("3000000000", Conversation.formatSliderValue(3_000_000_000f, "xs:long"));