Skip to content

Commit caed74c

Browse files
authored
dataconnect: fix generation of invalid timestamp values in internal test utilities (#6689)
1 parent 2d1905e commit caed74c

File tree

1 file changed

+174
-26
lines changed
  • firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary

1 file changed

+174
-26
lines changed

firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/javatime.kt

Lines changed: 174 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ import com.google.firebase.dataconnect.testutil.property.arbitrary.JavaTimeEdgeC
2424
import com.google.firebase.dataconnect.testutil.property.arbitrary.JavaTimeEdgeCases.MIN_NANO
2525
import com.google.firebase.dataconnect.testutil.property.arbitrary.JavaTimeEdgeCases.MIN_YEAR
2626
import com.google.firebase.dataconnect.testutil.toTimestamp
27+
import io.kotest.common.mapError
2728
import io.kotest.property.Arb
2829
import io.kotest.property.arbitrary.arbitrary
2930
import io.kotest.property.arbitrary.choice
3031
import io.kotest.property.arbitrary.enum
3132
import io.kotest.property.arbitrary.int
3233
import io.kotest.property.arbitrary.of
3334
import io.kotest.property.arbitrary.orNull
34-
import kotlin.random.nextInt
3535
import org.threeten.bp.Instant
3636
import org.threeten.bp.OffsetDateTime
3737
import org.threeten.bp.ZoneOffset
@@ -153,7 +153,11 @@ private fun Instant.toFdcFieldRegex(): Regex {
153153
return Regex(pattern)
154154
}
155155

156-
data class Nanoseconds(val nanoseconds: Int, val string: String)
156+
data class Nanoseconds(
157+
val nanoseconds: Int,
158+
val string: String,
159+
val digitCounts: JavaTimeArbs.NanosecondComponents
160+
)
157161

158162
sealed interface TimeOffset {
159163

@@ -177,8 +181,12 @@ sealed interface TimeOffset {
177181

178182
data class HhMm(val hours: Int, val minutes: Int, val sign: Sign) : TimeOffset {
179183
init {
180-
require(hours in 0..18) { "invalid hours: $hours (must be in the closed range 0..23)" }
181-
require(minutes in 0..59) { "invalid minutes: $minutes (must be in the closed range 0..59)" }
184+
require(hours in validHours) {
185+
"invalid hours: $hours (must be in the closed range $validHours)"
186+
}
187+
require(minutes in validMinutes) {
188+
"invalid minutes: $minutes (must be in the closed range $validMinutes)"
189+
}
182190
require(hours != 18 || minutes == 0) { "invalid minutes: $minutes (must be 0 when hours=18)" }
183191
}
184192

@@ -192,15 +200,44 @@ sealed interface TimeOffset {
192200
append("$minutes".padStart(2, '0'))
193201
}
194202

203+
fun toSeconds(): Int {
204+
val absValue = (hours * SECONDS_PER_HOUR) + (minutes * SECONDS_PER_MINUTE)
205+
return when (sign) {
206+
Sign.Positive -> absValue
207+
Sign.Negative -> -absValue
208+
}
209+
}
210+
195211
override fun toString() =
196212
"HhMm(hours=$hours, minutes=$minutes, sign=$sign, " +
197213
"zoneOffset=$zoneOffset, rfc3339String=$rfc3339String)"
198214

215+
operator fun compareTo(other: HhMm): Int = toSeconds() - other.toSeconds()
216+
199217
@Suppress("unused")
200218
enum class Sign(val char: Char, val multiplier: Int) {
201219
Positive('+', 1),
202220
Negative('-', -1),
203221
}
222+
223+
companion object {
224+
private const val SECONDS_PER_MINUTE: Int = 60
225+
private const val SECONDS_PER_HOUR: Int = 60 * SECONDS_PER_MINUTE
226+
227+
val validHours = 0..18
228+
val validMinutes = 0..59
229+
230+
val maxSeconds: Int = 18 * SECONDS_PER_HOUR
231+
232+
fun forSeconds(seconds: Int, sign: Sign): HhMm {
233+
require(seconds in 0..maxSeconds) {
234+
"invalid seconds: $seconds (must be between 0 and $maxSeconds, inclusive)"
235+
}
236+
val hours = seconds / SECONDS_PER_HOUR
237+
val minutes = (seconds - (hours * SECONDS_PER_HOUR)) / SECONDS_PER_MINUTE
238+
return HhMm(hours = hours, minutes = minutes, sign = sign)
239+
}
240+
}
204241
}
205242
}
206243

@@ -215,7 +252,6 @@ object JavaTimeArbs {
215252
val minuteArb = minute()
216253
val secondArb = second()
217254
val nanosecondArb = nanosecond().orNull(nullProbability = 0.15)
218-
val timeOffsetArb = timeOffset()
219255

220256
return arbitrary(JavaTimeInstantEdgeCases.all) {
221257
val year = yearArb.bind()
@@ -226,7 +262,55 @@ object JavaTimeArbs {
226262
val minute = minuteArb.bind()
227263
val second = secondArb.bind()
228264
val nanosecond = nanosecondArb.bind()
229-
val timeOffset = timeOffsetArb.bind()
265+
266+
val instantUtc =
267+
OffsetDateTime.of(
268+
year,
269+
month,
270+
day,
271+
hour,
272+
minute,
273+
second,
274+
nanosecond?.nanoseconds ?: 0,
275+
ZoneOffset.UTC,
276+
)
277+
.toInstant()
278+
279+
// The valid range below was copied from:
280+
// com.google.firebase.Timestamp.Timestamp.validateRange() 253_402_300_800
281+
val validEpochSecondRange = -62_135_596_800..253_402_300_800
282+
283+
val numSecondsBelowMaxEpochSecond = validEpochSecondRange.last - instantUtc.epochSecond
284+
require(numSecondsBelowMaxEpochSecond > 0) {
285+
"internal error gh98nqedss: " +
286+
"invalid numSecondsBelowMaxEpochSecond: $numSecondsBelowMaxEpochSecond"
287+
}
288+
val minTimeZoneOffset =
289+
if (numSecondsBelowMaxEpochSecond >= TimeOffset.HhMm.maxSeconds) {
290+
null
291+
} else {
292+
TimeOffset.HhMm.forSeconds(
293+
numSecondsBelowMaxEpochSecond.toInt(),
294+
TimeOffset.HhMm.Sign.Negative
295+
)
296+
}
297+
298+
val numSecondsAboveMinEpochSecond = instantUtc.epochSecond - validEpochSecondRange.first
299+
require(numSecondsAboveMinEpochSecond > 0) {
300+
"internal error mje6a4mrbm: " +
301+
"invalid numSecondsAboveMinEpochSecond: $numSecondsAboveMinEpochSecond"
302+
}
303+
val maxTimeZoneOffset =
304+
if (numSecondsAboveMinEpochSecond >= TimeOffset.HhMm.maxSeconds) {
305+
null
306+
} else {
307+
TimeOffset.HhMm.forSeconds(
308+
numSecondsAboveMinEpochSecond.toInt(),
309+
TimeOffset.HhMm.Sign.Positive
310+
)
311+
}
312+
313+
val timeOffset = timeOffset(min = minTimeZoneOffset, max = maxTimeZoneOffset).bind()
230314

231315
val instant =
232316
OffsetDateTime.of(
@@ -241,6 +325,27 @@ object JavaTimeArbs {
241325
)
242326
.toInstant()
243327

328+
require(instant.epochSecond >= validEpochSecondRange.first) {
329+
"internal error weppxzqj2y: " +
330+
"instant.epochSecond out of range by " +
331+
"${validEpochSecondRange.first - instant.epochSecond}: ${instant.epochSecond} (" +
332+
"validEpochSecondRange.first=${validEpochSecondRange.first}, " +
333+
"year=$year, month=$month, day=$day, " +
334+
"hour=$hour, minute=$minute, second=$second, " +
335+
"nanosecond=$nanosecond timeOffset=$timeOffset, " +
336+
"minTimeZoneOffset=$minTimeZoneOffset, maxTimeZoneOffset=$maxTimeZoneOffset)"
337+
}
338+
require(instant.epochSecond <= validEpochSecondRange.last) {
339+
"internal error yxga5xy9bm: " +
340+
"instant.epochSecond out of range by " +
341+
"${instant.epochSecond - validEpochSecondRange.last}: ${instant.epochSecond} (" +
342+
"validEpochSecondRange.last=${validEpochSecondRange.last}, " +
343+
"year=$year, month=$month, day=$day, " +
344+
"hour=$hour, minute=$minute, second=$second, " +
345+
"nanosecond=$nanosecond timeOffset=$timeOffset, " +
346+
"minTimeZoneOffset=$minTimeZoneOffset, maxTimeZoneOffset=$maxTimeZoneOffset)"
347+
}
348+
244349
val string = buildString {
245350
append(year)
246351
append('-')
@@ -268,7 +373,10 @@ object JavaTimeArbs {
268373
}
269374
}
270375

271-
fun timeOffset(): Arb<TimeOffset> = Arb.choice(timeOffsetUtc(), timeOffsetHhMm())
376+
fun timeOffset(
377+
min: TimeOffset.HhMm?,
378+
max: TimeOffset.HhMm?,
379+
): Arb<TimeOffset> = Arb.choice(timeOffsetUtc(), timeOffsetHhMm(min = min, max = max))
272380

273381
fun timeOffsetUtc(
274382
case: Arb<TimeOffset.Utc.Case> = Arb.enum(),
@@ -278,20 +386,45 @@ object JavaTimeArbs {
278386
sign: Arb<TimeOffset.HhMm.Sign> = Arb.enum(),
279387
hour: Arb<Int> = Arb.positiveIntWithUniformNumDigitsProbability(0..18),
280388
minute: Arb<Int> = minute(),
281-
): Arb<TimeOffset.HhMm> =
282-
arbitrary(
389+
min: TimeOffset.HhMm?,
390+
max: TimeOffset.HhMm?,
391+
): Arb<TimeOffset.HhMm> {
392+
require(min === null || max === null || min.toSeconds() < max.toSeconds()) {
393+
"min must be strictly less than max, but got: " +
394+
"min=$min (${min!!.toSeconds()} seconds), " +
395+
"max=$max (${max!!.toSeconds()} seconds), " +
396+
"a difference of ${min.toSeconds() - max.toSeconds()} seconds"
397+
}
398+
399+
fun isBetweenMinAndMax(other: TimeOffset.HhMm): Boolean =
400+
(min === null || other >= min) && (max === null || other <= max)
401+
402+
return arbitrary(
283403
edgecases =
284404
listOf(
285-
TimeOffset.HhMm(hours = 0, minutes = 0, sign = TimeOffset.HhMm.Sign.Positive),
286-
TimeOffset.HhMm(hours = 0, minutes = 0, sign = TimeOffset.HhMm.Sign.Negative),
287-
TimeOffset.HhMm(hours = 17, minutes = 59, sign = TimeOffset.HhMm.Sign.Positive),
288-
TimeOffset.HhMm(hours = 17, minutes = 59, sign = TimeOffset.HhMm.Sign.Negative),
289-
TimeOffset.HhMm(hours = 18, minutes = 0, sign = TimeOffset.HhMm.Sign.Positive),
290-
TimeOffset.HhMm(hours = 18, minutes = 0, sign = TimeOffset.HhMm.Sign.Negative),
291-
)
405+
TimeOffset.HhMm(hours = 0, minutes = 0, sign = TimeOffset.HhMm.Sign.Positive),
406+
TimeOffset.HhMm(hours = 0, minutes = 0, sign = TimeOffset.HhMm.Sign.Negative),
407+
TimeOffset.HhMm(hours = 17, minutes = 59, sign = TimeOffset.HhMm.Sign.Positive),
408+
TimeOffset.HhMm(hours = 17, minutes = 59, sign = TimeOffset.HhMm.Sign.Negative),
409+
TimeOffset.HhMm(hours = 18, minutes = 0, sign = TimeOffset.HhMm.Sign.Positive),
410+
TimeOffset.HhMm(hours = 18, minutes = 0, sign = TimeOffset.HhMm.Sign.Negative),
411+
)
412+
.filter(::isBetweenMinAndMax)
292413
) {
293-
TimeOffset.HhMm(hours = hour.bind(), minutes = minute.bind(), sign = sign.bind())
414+
var count = 0
415+
var hhmm: TimeOffset.HhMm
416+
while (true) {
417+
count++
418+
hhmm = TimeOffset.HhMm(hours = hour.bind(), minutes = minute.bind(), sign = sign.bind())
419+
if (isBetweenMinAndMax(hhmm)) {
420+
break
421+
} else if (count > 1000) {
422+
throw Exception("internal error j878fp4gmr: exhausted attempts to generate HhMm")
423+
}
424+
}
425+
hhmm
294426
}
427+
}
295428

296429
fun year(): Arb<Int> = Arb.int(MIN_YEAR..MAX_YEAR)
297430

@@ -316,8 +449,12 @@ object JavaTimeArbs {
316449
repeat(digitCounts.leadingZeroes) { append('0') }
317450
if (digitCounts.proper > 0) {
318451
append(nonZeroDigits.bind())
319-
repeat(digitCounts.proper - 2) { append(digits.bind()) }
320-
append(nonZeroDigits.bind())
452+
if (digitCounts.proper > 1) {
453+
if (digitCounts.proper > 2) {
454+
repeat(digitCounts.proper - 2) { append(digits.bind()) }
455+
}
456+
append(nonZeroDigits.bind())
457+
}
321458
}
322459
repeat(digitCounts.trailingZeroes) { append('0') }
323460
}
@@ -327,18 +464,29 @@ object JavaTimeArbs {
327464
if (nanosecondsStringTrimmed.isEmpty()) {
328465
0
329466
} else {
330-
nanosecondsStringTrimmed.toInt()
467+
val toIntResult = nanosecondsStringTrimmed.runCatching { toInt() }
468+
toIntResult.mapError { exception ->
469+
Exception(
470+
"internal error qbdgapmye2: " +
471+
"failed to parse nanosecondsStringTrimmed as an int: " +
472+
"\"$nanosecondsStringTrimmed\" (digitCounts=$digitCounts)",
473+
exception
474+
)
475+
}
476+
toIntResult.getOrThrow()
331477
}
332478

333-
Nanoseconds(nanosecondsInt, nanosecondsString)
479+
check(nanosecondsInt in 0..999_999_999) {
480+
"internal error c7j2myw6bd: " +
481+
"nanosecondsStringTrimmed parsed to a value outside the valid range: " +
482+
"$nanosecondsInt (digitCounts=$digitCounts)"
483+
}
484+
485+
Nanoseconds(nanosecondsInt, nanosecondsString, digitCounts)
334486
}
335487
}
336488

337-
private data class NanosecondComponents(
338-
val leadingZeroes: Int,
339-
val proper: Int,
340-
val trailingZeroes: Int
341-
)
489+
data class NanosecondComponents(val leadingZeroes: Int, val proper: Int, val trailingZeroes: Int)
342490

343491
private fun nanosecondComponents(): Arb<NanosecondComponents> =
344492
arbitrary(

0 commit comments

Comments
 (0)