Skip to content

Commit 21c9e97

Browse files
authored
Correctly parse invalid numbers in JsonLiteral.long and other extensions (#2852)
Content must be consumed fully, with no leftovers after number. Also simplify try/catching logic — JsonLiteral.long should throw NumberFormatException, while decoding from JsonElement should throw JsonDecodingException Fixes #2849
1 parent d15dfee commit 21c9e97

File tree

5 files changed

+57
-13
lines changed

5 files changed

+57
-13
lines changed

formats/json-tests/commonTest/src/kotlinx/serialization/json/JsonElementDecodingTest.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package kotlinx.serialization.json
33
import kotlinx.serialization.*
44
import kotlinx.serialization.descriptors.*
55
import kotlinx.serialization.encoding.*
6+
import kotlinx.serialization.test.*
67
import kotlin.test.*
78

89
class JsonElementDecodingTest : JsonTestBase() {
@@ -107,4 +108,13 @@ class JsonElementDecodingTest : JsonTestBase() {
107108
assertJsonFormAndRestored(Wrapper.serializer(), Wrapper(value = JsonNull), """{"value":null}""", noExplicitNullsOrDefaultsJson)
108109
assertJsonFormAndRestored(Wrapper.serializer(), Wrapper(value = null), """{}""", noExplicitNullsOrDefaultsJson)
109110
}
111+
112+
@Test
113+
fun testLiteralIncorrectParsing() {
114+
val str = """{"a": "3 digit then random string"}"""
115+
val obj = Json.decodeFromString<JsonObject>(str)
116+
assertFailsWithMessage<NumberFormatException>("Expected input to contain a single valid number") {
117+
println(obj.getValue("a").jsonPrimitive.long)
118+
}
119+
}
110120
}

formats/json-tests/commonTest/src/kotlinx/serialization/json/serializers/JsonPrimitiveSerializerTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
package kotlinx.serialization.json.serializers
66

7+
import kotlinx.serialization.Serializable
78
import kotlinx.serialization.json.*
89
import kotlinx.serialization.test.*
910
import kotlin.test.*
@@ -201,4 +202,17 @@ class JsonPrimitiveSerializerTest : JsonTestBase() {
201202
assertUnsignedNumberEncoding(expected, actual, JsonPrimitive(actual))
202203
}
203204
}
205+
206+
@Serializable
207+
class OuterLong(val a: Long)
208+
209+
@Test
210+
fun testRejectingIncorrectNumbers() = parametrizedTest { mode ->
211+
checkSerializationException({
212+
default.decodeFromString(OuterLong.serializer(), """{"a":"12:34:45"}""", mode)
213+
}, {
214+
if (mode == JsonTestingMode.TREE) assertContains(it, "Failed to parse literal '\"12:34:45\"' as a long value at element: \$.a")
215+
else assertContains(it, "Unexpected JSON token at offset 5: Expected closing quotation mark at path: \$.a")
216+
})
217+
}
204218
}

formats/json/commonMain/src/kotlinx/serialization/json/JsonElement.kt

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ public val JsonElement.jsonNull: JsonNull
256256
*/
257257
public val JsonPrimitive.int: Int
258258
get() {
259-
val result = mapExceptions { StringJsonLexer(content).consumeNumericLiteral() }
259+
val result = exceptionToNumberFormatException { parseLongImpl() }
260260
if (result !in Int.MIN_VALUE..Int.MAX_VALUE) throw NumberFormatException("$content is not an Int")
261261
return result.toInt()
262262
}
@@ -266,7 +266,7 @@ public val JsonPrimitive.int: Int
266266
*/
267267
public val JsonPrimitive.intOrNull: Int?
268268
get() {
269-
val result = mapExceptionsToNull { StringJsonLexer(content).consumeNumericLiteral() } ?: return null
269+
val result = exceptionToNull { parseLongImpl() } ?: return null
270270
if (result !in Int.MIN_VALUE..Int.MAX_VALUE) return null
271271
return result.toInt()
272272
}
@@ -275,14 +275,13 @@ public val JsonPrimitive.intOrNull: Int?
275275
* Returns content of current element as long
276276
* @throws NumberFormatException if current element is not a valid representation of number
277277
*/
278-
public val JsonPrimitive.long: Long get() = mapExceptions { StringJsonLexer(content).consumeNumericLiteral() }
278+
public val JsonPrimitive.long: Long get() = exceptionToNumberFormatException { parseLongImpl() }
279279

280280
/**
281281
* Returns content of current element as long or `null` if current element is not a valid representation of number
282282
*/
283283
public val JsonPrimitive.longOrNull: Long?
284-
get() =
285-
mapExceptionsToNull { StringJsonLexer(content).consumeNumericLiteral() }
284+
get() = exceptionToNull { parseLongImpl() }
286285

287286
/**
288287
* Returns content of current element as double
@@ -326,15 +325,15 @@ public val JsonPrimitive.contentOrNull: String? get() = if (this is JsonNull) nu
326325
private fun JsonElement.error(element: String): Nothing =
327326
throw IllegalArgumentException("Element ${this::class} is not a $element")
328327

329-
private inline fun <T> mapExceptionsToNull(f: () -> T): T? {
328+
private inline fun <T> exceptionToNull(f: () -> T): T? {
330329
return try {
331330
f()
332331
} catch (e: JsonDecodingException) {
333332
null
334333
}
335334
}
336335

337-
private inline fun <T> mapExceptions(f: () -> T): T {
336+
private inline fun <T> exceptionToNumberFormatException(f: () -> T): T {
338337
return try {
339338
f()
340339
} catch (e: JsonDecodingException) {
@@ -345,3 +344,6 @@ private inline fun <T> mapExceptions(f: () -> T): T {
345344
@PublishedApi
346345
internal fun unexpectedJson(key: String, expected: String): Nothing =
347346
throw IllegalArgumentException("Element $key is not a $expected")
347+
348+
// Use this function to avoid re-wrapping exception into NumberFormatException
349+
internal fun JsonPrimitive.parseLongImpl(): Long = StringJsonLexer(content).consumeNumericLiteralFully()

formats/json/commonMain/src/kotlinx/serialization/json/internal/TreeJsonDecoder.kt

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,19 +112,24 @@ private sealed class AbstractJsonTreeDecoder(
112112
getPrimitiveValue(tag, "boolean", JsonPrimitive::booleanOrNull)
113113

114114
override fun decodeTaggedByte(tag: String) = getPrimitiveValue(tag, "byte") {
115-
val result = int
115+
val result = parseLongImpl()
116116
if (result in Byte.MIN_VALUE..Byte.MAX_VALUE) result.toByte()
117117
else null
118118
}
119119

120120
override fun decodeTaggedShort(tag: String) = getPrimitiveValue(tag, "short") {
121-
val result = int
121+
val result = parseLongImpl()
122122
if (result in Short.MIN_VALUE..Short.MAX_VALUE) result.toShort()
123123
else null
124124
}
125125

126-
override fun decodeTaggedInt(tag: String) = getPrimitiveValue(tag, "int") { int }
127-
override fun decodeTaggedLong(tag: String) = getPrimitiveValue(tag, "long") { long }
126+
override fun decodeTaggedInt(tag: String) = getPrimitiveValue(tag, "int") {
127+
val result = parseLongImpl()
128+
if (result in Int.MIN_VALUE..Int.MAX_VALUE) result.toInt()
129+
else null
130+
}
131+
132+
override fun decodeTaggedLong(tag: String) = getPrimitiveValue(tag, "long") { parseLongImpl() }
128133

129134
override fun decodeTaggedFloat(tag: String): Float {
130135
val result = getPrimitiveValue(tag, "float") { float }

formats/json/commonMain/src/kotlinx/serialization/json/internal/lexer/AbstractJsonLexer.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,12 +223,16 @@ internal abstract class AbstractJsonLexer {
223223
fail(charToTokenClass(expected))
224224
}
225225

226-
internal fun fail(expectedToken: Byte, wasConsumed: Boolean = true): Nothing {
226+
internal inline fun fail(
227+
expectedToken: Byte,
228+
wasConsumed: Boolean = true,
229+
message: (expected: String, source: String) -> String = { expected, source -> "Expected $expected, but had '$source' instead" }
230+
): Nothing {
227231
// Slow path, never called in normal code, can avoid optimizing it
228232
val expected = tokenDescription(expectedToken)
229233
val position = if (wasConsumed) currentPosition - 1 else currentPosition
230234
val s = if (currentPosition == source.length || position < 0) "EOF" else source[position].toString()
231-
fail("Expected $expected, but had '$s' instead", position)
235+
fail(message(expected, s), position)
232236
}
233237

234238
open fun peekNextToken(): Byte {
@@ -671,6 +675,15 @@ internal abstract class AbstractJsonLexer {
671675
}
672676
}
673677

678+
fun consumeNumericLiteralFully(): Long {
679+
val result = consumeNumericLiteral()
680+
val next = consumeNextToken()
681+
if (next != TC_EOF) {
682+
fail(TC_EOF) { _, source -> "Expected input to contain a single valid number, but got '$source' after it" }
683+
}
684+
return result
685+
}
686+
674687

675688
fun consumeBoolean(): Boolean {
676689
return consumeBoolean(skipWhitespaces())

0 commit comments

Comments
 (0)