Skip to content

Commit da6f678

Browse files
authored
Prohibit parsing of non-ASCII digits (#413)
Fixes #405
1 parent 29276fe commit da6f678

File tree

5 files changed

+131
-41
lines changed

5 files changed

+131
-41
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

+72-30
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`,
@@ -19,13 +18,16 @@ internal sealed class NumberConsumer<in Receiver>(
1918
val whatThisExpects: String
2019
) {
2120
/**
22-
* Wholly consumes the given [input]. Should be called with a string consisting of [length] digits, or,
23-
* if [length] is `null`, with a string consisting of any number of digits. [consume] itself does not
24-
* necessarily check the length of the input string, instead expecting to be passed a valid one.
21+
* Wholly consumes the substring of [input] between indices [start] (inclusive) and [end] (exclusive).
22+
*
23+
* If [length] is non-null, [end] must be equal to [start] + [length].
24+
* In any case, the substring between [start] and [end] must consist of ASCII digits only.
25+
* [consume] itself does not necessarily check the length of the input string,
26+
* instead expecting to be given a valid one.
2527
*
2628
* Returns `null` on success and a `NumberConsumptionError` on failure.
2729
*/
28-
abstract fun consume(storage: Receiver, input: String): NumberConsumptionError?
30+
abstract fun consume(storage: Receiver, input: CharSequence, start: Int, end: Int): NumberConsumptionError?
2931
}
3032

3133
internal interface NumberConsumptionError {
@@ -50,7 +52,6 @@ internal interface NumberConsumptionError {
5052
/**
5153
* A parser that accepts an [Int] value in range from `0` to [Int.MAX_VALUE].
5254
*/
53-
// TODO: should the parser reject excessive padding?
5455
internal class UnsignedIntConsumer<in Receiver>(
5556
private val minLength: Int?,
5657
private val maxLength: Int?,
@@ -63,10 +64,10 @@ internal class UnsignedIntConsumer<in Receiver>(
6364
require(length == null || length in 1..9) { "Invalid length for field $whatThisExpects: $length" }
6465
}
6566

66-
override fun consume(storage: Receiver, input: String): NumberConsumptionError? = when {
67-
maxLength != null && input.length > maxLength -> NumberConsumptionError.TooManyDigits(maxLength)
68-
minLength != null && input.length < minLength -> NumberConsumptionError.TooFewDigits(minLength)
69-
else -> when (val result = input.toIntOrNull()) {
67+
override fun consume(storage: Receiver, input: CharSequence, start: Int, end: Int): NumberConsumptionError? = when {
68+
maxLength != null && end - start > maxLength -> NumberConsumptionError.TooManyDigits(maxLength)
69+
minLength != null && end - start < minLength -> NumberConsumptionError.TooFewDigits(minLength)
70+
else -> when (val result = input.parseAsciiIntOrNull(start = start, end = end)) {
7071
null -> NumberConsumptionError.ExpectedInt
7172
else -> setter.setWithoutReassigning(storage, if (multiplyByMinus1) -result else result)
7273
}
@@ -84,9 +85,13 @@ internal class ReducedIntConsumer<in Receiver>(
8485
private val baseMod = base % modulo
8586
private val baseFloor = base - baseMod
8687

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) {
88+
init {
89+
require(length in 1..9) { "Invalid length for field $whatThisExpects: $length" }
90+
}
91+
92+
override fun consume(storage: Receiver, input: CharSequence, start: Int, end: Int): NumberConsumptionError? {
93+
val result = input.parseAsciiInt(start = start, end = end)
94+
return setter.setWithoutReassigning(storage, if (result >= baseMod) {
9095
baseFloor + result
9196
} else {
9297
baseFloor + modulo + result
@@ -100,31 +105,35 @@ internal class ReducedIntConsumer<in Receiver>(
100105
internal class ConstantNumberConsumer<in Receiver>(
101106
private val expected: String
102107
) : NumberConsumer<Receiver>(expected.length, "the predefined string $expected") {
103-
override fun consume(storage: Receiver, input: String): NumberConsumptionError? = if (input == expected) {
104-
null
105-
} else {
106-
NumberConsumptionError.WrongConstant(expected)
107-
}
108+
override fun consume(storage: Receiver, input: CharSequence, start: Int, end: Int): NumberConsumptionError? =
109+
if (input.substring(startIndex = start, endIndex = end) == expected) {
110+
null
111+
} else {
112+
NumberConsumptionError.WrongConstant(expected)
113+
}
108114
}
109115

110116
internal class FractionPartConsumer<in Receiver>(
111-
private val minLength: Int?,
112-
private val maxLength: Int?,
117+
private val minLength: Int,
118+
private val maxLength: Int,
113119
private val setter: AssignableField<Receiver, DecimalFraction>,
114120
name: String,
115121
) : NumberConsumer<Receiver>(if (minLength == maxLength) minLength else null, name) {
116122
init {
117-
require(minLength == null || minLength in 1..9) { "Invalid length for field $whatThisExpects: $length" }
118-
// TODO: bounds on maxLength
123+
require(minLength in 1..9) {
124+
"Invalid minimum length $minLength for field $whatThisExpects: expected 1..9"
125+
}
126+
require(maxLength in minLength..9) {
127+
"Invalid maximum length $maxLength for field $whatThisExpects: expected $minLength..9"
128+
}
119129
}
120130

121-
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-
}
131+
override fun consume(storage: Receiver, input: CharSequence, start: Int, end: Int): NumberConsumptionError? = when {
132+
end - start < minLength -> NumberConsumptionError.TooFewDigits(minLength)
133+
end - start > maxLength -> NumberConsumptionError.TooManyDigits(maxLength)
134+
else -> setter.setWithoutReassigning(
135+
storage, DecimalFraction(input.parseAsciiInt(start = start, end = end), end - start)
136+
)
128137
}
129138
}
130139

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

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

+7-5
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)
@@ -87,9 +89,9 @@ internal class NumberSpanParserOperation<Output>(
8789
var index = startIndex
8890
for (i in consumers.indices) {
8991
val length = consumers[i].length ?: (digitsInRow - minLength + 1)
90-
val numberString = input.substring(index, index + length)
91-
val error = consumers[i].consume(storage, numberString)
92+
val error = consumers[i].consume(storage, input, index, index + length)
9293
if (error != null) {
94+
val numberString = input.substring(index, index + length)
9395
return ParseResult.Error(index) {
9496
"Can not interpret the string '$numberString' as ${consumers[i].whatThisExpects}: ${error.errorMessage()}"
9597
}

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'

core/common/test/format/DateTimeFormatTest.kt

+31
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,37 @@ class DateTimeFormatTest {
149149
}
150150
assertEquals(UtcOffset(-7, -30), format.parse("-730"))
151151
}
152+
153+
@Test
154+
fun testNotParsingNonAsciiNumbers() {
155+
val formatWithFraction = DateTimeComponents.Format {
156+
secondFraction(3)
157+
}
158+
formatWithFraction.parse("999")
159+
assertFailsWith<IllegalArgumentException> {
160+
formatWithFraction.parse("٩٩٩")
161+
}
162+
val formatWithArbitraryWidthNumber = DateTimeComponents.Format {
163+
year()
164+
}
165+
formatWithArbitraryWidthNumber.parse("+99999")
166+
assertFailsWith<IllegalArgumentException> {
167+
formatWithArbitraryWidthNumber.parse("+٩٩٩٩٩")
168+
}
169+
val formatWithFixedWidthNumber = DateTimeComponents.Format {
170+
monthNumber()
171+
}
172+
formatWithFixedWidthNumber.parse("99")
173+
assertFailsWith<IllegalArgumentException> {
174+
formatWithFixedWidthNumber.parse("٩٩")
175+
}
176+
val formatWithNonAsciiNumberInString = DateTimeComponents.Format {
177+
chars("99")
178+
chars("٩٩")
179+
chars("99")
180+
}
181+
formatWithNonAsciiNumberInString.parse("99٩٩99")
182+
}
152183
}
153184

154185
fun <T> DateTimeFormat<T>.assertCanNotParse(input: String) {

0 commit comments

Comments
 (0)