From 38a4581513008874ff84a87578a834f0e2ad4342 Mon Sep 17 00:00:00 2001 From: Chirag Date: Sun, 9 Aug 2026 04:27:25 +0530 Subject: [PATCH 1/2] Fix Duration config parsing for empty, uppercase and compound values getSeconds() took the unit from the last character and silently stripped everything else, so an empty value crashed with StringIndexOutOfBoundsException and values like 10H or 1h30m parsed to the wrong number of seconds. Parse units case-insensitively, support compound durations, and reject empty or invalid input with a clear error instead of crashing. --- .../paper/configuration/type/Duration.java | 63 +++++++++++++------ .../paper/configuration/DurationTest.java | 51 +++++++++++++++ 2 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 paper-server/src/test/java/io/papermc/paper/configuration/DurationTest.java diff --git a/paper-server/src/main/java/io/papermc/paper/configuration/type/Duration.java b/paper-server/src/main/java/io/papermc/paper/configuration/type/Duration.java index ad1c77388da8..85b06c2cdff0 100644 --- a/paper-server/src/main/java/io/papermc/paper/configuration/type/Duration.java +++ b/paper-server/src/main/java/io/papermc/paper/configuration/type/Duration.java @@ -1,8 +1,10 @@ package io.papermc.paper.configuration.type; import java.lang.reflect.Type; +import java.util.Locale; import java.util.Objects; import java.util.function.Predicate; +import java.util.regex.Matcher; import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; import org.spongepowered.configurate.serialize.ScalarSerializer; @@ -10,8 +12,9 @@ public final class Duration { - private static final Pattern SPACE = Pattern.compile(" "); - private static final Pattern NOT_NUMERIC = Pattern.compile("[^-\\d.]"); + private static final Pattern SPACE = Pattern.compile("\\s+"); + private static final Pattern PLAIN_NUMBER = Pattern.compile("-?\\d+(\\.\\d+)?"); + private static final Pattern DURATION = Pattern.compile("(\\d+(?:\\.\\d+)?)([dhms])", Pattern.CASE_INSENSITIVE); public static final ScalarSerializer SERIALIZER = new Serializer(); private final long seconds; @@ -59,23 +62,43 @@ public static Duration of(String time) { return new Duration(time); } - private static int getSeconds(String str) { - str = SPACE.matcher(str).replaceAll(""); - final char unit = str.charAt(str.length() - 1); - str = NOT_NUMERIC.matcher(str).replaceAll(""); - double num; - try { - num = Double.parseDouble(str); - } catch (Exception e) { - num = 0D; + private static long getSeconds(String str) { + if (str == null || str.isBlank()) { + throw new IllegalArgumentException("Duration value must not be empty: '" + str + "'"); } - switch (unit) { - case 'd': num *= (double) 60*60*24; break; - case 'h': num *= (double) 60*60; break; - case 'm': num *= (double) 60; break; - default: case 's': break; + str = SPACE.matcher(str).replaceAll("").toLowerCase(Locale.ROOT); + if (str.isEmpty()) { + throw new IllegalArgumentException("Duration value must not be empty"); } - return (int) num; + + // A plain number (optionally negative or decimal) is interpreted as seconds. + if (PLAIN_NUMBER.matcher(str).matches()) { + return (long) Double.parseDouble(str); + } + + long totalSeconds = 0; + final Matcher matcher = DURATION.matcher(str); + int lastEnd = 0; + boolean matched = false; + while (matcher.find()) { + if (matcher.start() != lastEnd) { + throw new IllegalArgumentException("Invalid duration value: '" + str + "'"); + } + final double amount = Double.parseDouble(matcher.group(1)); + totalSeconds += (long) (amount * switch (matcher.group(2).charAt(0)) { + case 'd' -> 86400.0; + case 'h' -> 3600.0; + case 'm' -> 60.0; + case 's' -> 1.0; + default -> throw new IllegalStateException("Unreachable"); + }); + lastEnd = matcher.end(); + matched = true; + } + if (!matched || lastEnd != str.length()) { + throw new IllegalArgumentException("Invalid duration value: '" + str + "'"); + } + return totalSeconds; } static final class Serializer extends ScalarSerializer { @@ -85,7 +108,11 @@ private Serializer() { @Override public Duration deserialize(Type type, Object obj) throws SerializationException { - return new Duration(obj.toString()); + try { + return new Duration(obj.toString()); + } catch (final IllegalArgumentException ex) { + throw new SerializationException(type, ex); + } } @Override diff --git a/paper-server/src/test/java/io/papermc/paper/configuration/DurationTest.java b/paper-server/src/test/java/io/papermc/paper/configuration/DurationTest.java new file mode 100644 index 000000000000..5940a8ebe35d --- /dev/null +++ b/paper-server/src/test/java/io/papermc/paper/configuration/DurationTest.java @@ -0,0 +1,51 @@ +package io.papermc.paper.configuration; + +import io.papermc.paper.configuration.type.Duration; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DurationTest { + + @Test + void testBasicUnits() { + assertEquals(10, Duration.of("10s").seconds()); + assertEquals(1500, Duration.of("25m").seconds()); + assertEquals(43200, Duration.of("12h").seconds()); + assertEquals(172800, Duration.of("2d").seconds()); + } + + @Test + void testDecimals() { + assertEquals(5400, Duration.of("1.5h").seconds()); + } + + @Test + void testWhitespaceIgnored() { + assertEquals(3600, Duration.of(" 1h ").seconds()); + } + + @Test + void testUppercaseUnit() { + assertEquals(36000, Duration.of("10H").seconds()); + } + + @Test + void testCompoundDuration() { + assertEquals(5400, Duration.of("1h30m").seconds()); + assertEquals(3661, Duration.of("1h1m1s").seconds()); + } + + @Test + void testEmptyStringRejected() { + assertThrows(IllegalArgumentException.class, () -> Duration.of("")); + assertThrows(IllegalArgumentException.class, () -> Duration.of(" ")); + } + + @Test + void testInvalidInputRejected() { + assertThrows(IllegalArgumentException.class, () -> Duration.of("abc")); + assertThrows(IllegalArgumentException.class, () -> Duration.of("1h30")); + } +} From 8a41ddcdf5368c63fd9f70d0fc3c741a966f4e3b Mon Sep 17 00:00:00 2001 From: Chirag Date: Sun, 9 Aug 2026 04:42:00 +0530 Subject: [PATCH 2/2] Annotate DurationTest as Normal suite test --- .../test/java/io/papermc/paper/configuration/DurationTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/paper-server/src/test/java/io/papermc/paper/configuration/DurationTest.java b/paper-server/src/test/java/io/papermc/paper/configuration/DurationTest.java index 5940a8ebe35d..59878507bb3b 100644 --- a/paper-server/src/test/java/io/papermc/paper/configuration/DurationTest.java +++ b/paper-server/src/test/java/io/papermc/paper/configuration/DurationTest.java @@ -1,11 +1,13 @@ package io.papermc.paper.configuration; import io.papermc.paper.configuration.type.Duration; +import org.bukkit.support.environment.Normal; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +@Normal class DurationTest { @Test