Skip to content

Commit 57a66fe

Browse files
committed
Prohibit parsing of non-ASCII digits
Fixes #405
1 parent 11c5157 commit 57a66fe

File tree

4 files changed

+79
-26
lines changed

4 files changed

+79
-26
lines changed

core/common/src/internal/format/FormatStructure.kt

+11-6
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package kotlinx.datetime.internal.format
77

88
import kotlinx.datetime.internal.format.formatter.*
99
import kotlinx.datetime.internal.format.parser.*
10+
import kotlinx.datetime.internal.isAsciiDigit
1011

1112
internal sealed interface FormatStructure<in T> {
1213
fun parser(): ParserStructure<T>
@@ -37,16 +38,20 @@ internal class ConstantFormatStructure<in T>(
3738
operations = when {
3839
string.isEmpty() -> emptyList()
3940
else -> buildList {
40-
val suffix = if (string[0].isDigit()) {
41-
add(NumberSpanParserOperation(listOf(ConstantNumberConsumer(string.takeWhile { it.isDigit() }))))
42-
string.dropWhile { it.isDigit() }
41+
val suffix = if (string[0].isAsciiDigit()) {
42+
add(NumberSpanParserOperation(listOf(ConstantNumberConsumer(string.takeWhile {
43+
it.isAsciiDigit()
44+
}))))
45+
string.dropWhile { it.isAsciiDigit() }
4346
} else {
4447
string
4548
}
4649
if (suffix.isNotEmpty()) {
47-
if (suffix[suffix.length - 1].isDigit()) {
48-
add(PlainStringParserOperation(suffix.dropLastWhile { it.isDigit() }))
49-
add(NumberSpanParserOperation(listOf(ConstantNumberConsumer(suffix.takeLastWhile { it.isDigit() }))))
50+
if (suffix[suffix.length - 1].isAsciiDigit()) {
51+
add(PlainStringParserOperation(suffix.dropLastWhile { it.isAsciiDigit() }))
52+
add(NumberSpanParserOperation(listOf(ConstantNumberConsumer(suffix.takeLastWhile {
53+
it.isAsciiDigit()
54+
}))))
5055
} else {
5156
add(PlainStringParserOperation(suffix))
5257
}

core/common/src/internal/format/parser/NumberConsumer.kt

+53-17
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55

66
package kotlinx.datetime.internal.format.parser
77

8-
import kotlinx.datetime.internal.POWERS_OF_TEN
9-
import kotlinx.datetime.internal.DecimalFraction
8+
import kotlinx.datetime.internal.*
109

1110
/**
1211
* A parser that expects to receive a string consisting of [length] digits, or, if [length] is `null`,
@@ -50,7 +49,6 @@ internal interface NumberConsumptionError {
5049
/**
5150
* A parser that accepts an [Int] value in range from `0` to [Int.MAX_VALUE].
5251
*/
53-
// TODO: should the parser reject excessive padding?
5452
internal class UnsignedIntConsumer<in Receiver>(
5553
private val minLength: Int?,
5654
private val maxLength: Int?,
@@ -66,7 +64,7 @@ internal class UnsignedIntConsumer<in Receiver>(
6664
override fun consume(storage: Receiver, input: String): NumberConsumptionError? = when {
6765
maxLength != null && input.length > maxLength -> NumberConsumptionError.TooManyDigits(maxLength)
6866
minLength != null && input.length < minLength -> NumberConsumptionError.TooFewDigits(minLength)
69-
else -> when (val result = input.toIntOrNull()) {
67+
else -> when (val result = input.parseAsciiIntOrNull()) {
7068
null -> NumberConsumptionError.ExpectedInt
7169
else -> setter.setWithoutReassigning(storage, if (multiplyByMinus1) -result else result)
7270
}
@@ -84,9 +82,13 @@ internal class ReducedIntConsumer<in Receiver>(
8482
private val baseMod = base % modulo
8583
private val baseFloor = base - baseMod
8684

87-
override fun consume(storage: Receiver, input: String): NumberConsumptionError? = when (val result = input.toIntOrNull()) {
88-
null -> NumberConsumptionError.ExpectedInt
89-
else -> setter.setWithoutReassigning(storage, if (result >= baseMod) {
85+
init {
86+
require(length in 1..9) { "Invalid length for field $whatThisExpects: $length" }
87+
}
88+
89+
override fun consume(storage: Receiver, input: String): NumberConsumptionError? {
90+
val result = input.parseAsciiInt()
91+
return setter.setWithoutReassigning(storage, if (result >= baseMod) {
9092
baseFloor + result
9193
} else {
9294
baseFloor + modulo + result
@@ -108,23 +110,24 @@ internal class ConstantNumberConsumer<in Receiver>(
108110
}
109111

110112
internal class FractionPartConsumer<in Receiver>(
111-
private val minLength: Int?,
112-
private val maxLength: Int?,
113+
private val minLength: Int,
114+
private val maxLength: Int,
113115
private val setter: AssignableField<Receiver, DecimalFraction>,
114116
name: String,
115117
) : NumberConsumer<Receiver>(if (minLength == maxLength) minLength else null, name) {
116118
init {
117-
require(minLength == null || minLength in 1..9) { "Invalid length for field $whatThisExpects: $length" }
118-
// TODO: bounds on maxLength
119+
require(minLength in 1..9) {
120+
"Invalid minimum length $minLength for field $whatThisExpects: expected 1..9"
121+
}
122+
require(maxLength in minLength..9) {
123+
"Invalid maximum length $maxLength for field $whatThisExpects: expected $minLength..9"
124+
}
119125
}
120126

121127
override fun consume(storage: Receiver, input: String): NumberConsumptionError? = when {
122-
minLength != null && input.length < minLength -> NumberConsumptionError.TooFewDigits(minLength)
123-
maxLength != null && input.length > maxLength -> NumberConsumptionError.TooManyDigits(maxLength)
124-
else -> when (val numerator = input.toIntOrNull()) {
125-
null -> NumberConsumptionError.TooManyDigits(9)
126-
else -> setter.setWithoutReassigning(storage, DecimalFraction(numerator, input.length))
127-
}
128+
input.length < minLength -> NumberConsumptionError.TooFewDigits(minLength)
129+
input.length > maxLength -> NumberConsumptionError.TooManyDigits(maxLength)
130+
else -> setter.setWithoutReassigning(storage, DecimalFraction(input.parseAsciiInt(), input.length))
128131
}
129132
}
130133

@@ -135,3 +138,36 @@ private fun <Object, Type> AssignableField<Object, Type>.setWithoutReassigning(
135138
val conflictingValue = trySetWithoutReassigning(receiver, value) ?: return null
136139
return NumberConsumptionError.Conflicting(conflictingValue)
137140
}
141+
142+
/**
143+
* Parses a substring of the receiver string as a positive ASCII integer.
144+
*
145+
* All characters between [start] (inclusive) and [end] (exclusive) must be ASCII digits,
146+
* and the size of the substring must be at most 9, but the function does not check it.
147+
*/
148+
private fun String.parseAsciiInt(start: Int = 0, end: Int = length): Int {
149+
var result = 0
150+
for (i in start until end) {
151+
val digit = this[i]
152+
result = result * 10 + digit.asciiDigitToInt()
153+
}
154+
return result
155+
}
156+
157+
/**
158+
* Parses a substring of the receiver string as a positive ASCII integer.
159+
*
160+
* All characters between [start] (inclusive) and [end] (exclusive) must be ASCII digits,
161+
* but the function does not check it.
162+
*
163+
* Returns `null` if the result does not fit into a positive [Int].
164+
*/
165+
private fun String.parseAsciiIntOrNull(start: Int = 0, end: Int = length): Int? {
166+
var result = 0
167+
for (i in start until end) {
168+
val digit = this[i]
169+
result = result * 10 + digit.asciiDigitToInt()
170+
if (result < 0) return null
171+
}
172+
return result
173+
}

core/common/src/internal/format/parser/ParserOperation.kt

+5-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
package kotlinx.datetime.internal.format.parser
77

8+
import kotlinx.datetime.internal.isAsciiDigit
9+
810
internal interface ParserOperation<in Output> {
911
fun consume(storage: Output, input: CharSequence, startIndex: Int): ParseResult
1012
}
@@ -15,8 +17,8 @@ internal interface ParserOperation<in Output> {
1517
internal class PlainStringParserOperation<Output>(val string: String) : ParserOperation<Output> {
1618
init {
1719
require(string.isNotEmpty()) { "Empty string is not allowed" }
18-
require(!string[0].isDigit()) { "String '$string' starts with a digit" }
19-
require(!string[string.length - 1].isDigit()) { "String '$string' ends with a digit" }
20+
require(!string[0].isAsciiDigit()) { "String '$string' starts with a digit" }
21+
require(!string[string.length - 1].isAsciiDigit()) { "String '$string' ends with a digit" }
2022
}
2123

2224
override fun consume(storage: Output, input: CharSequence, startIndex: Int): ParseResult {
@@ -77,7 +79,7 @@ internal class NumberSpanParserOperation<Output>(
7779
if (startIndex + minLength > input.length)
7880
return ParseResult.Error(startIndex) { "Unexpected end of input: yet to parse $whatThisExpects" }
7981
var digitsInRow = 0
80-
while (startIndex + digitsInRow < input.length && input[startIndex + digitsInRow].isDigit()) {
82+
while (startIndex + digitsInRow < input.length && input[startIndex + digitsInRow].isAsciiDigit()) {
8183
++digitsInRow
8284
}
8385
if (digitsInRow < minLength)

core/common/src/internal/util.kt

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/*
2+
* Copyright 2019-2024 JetBrains s.r.o. and contributors.
3+
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
4+
*/
5+
6+
package kotlinx.datetime.internal
7+
8+
internal fun Char.isAsciiDigit(): Boolean = this in '0'..'9'
9+
10+
internal fun Char.asciiDigitToInt(): Int = this - '0'

0 commit comments

Comments
 (0)