Skip to content

Commit f0868b6

Browse files
committed
javatime.kt: ensure that time zone does not push the generated timestamp out of the valid range
1 parent a5d57b7 commit f0868b6

File tree

1 file changed

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

1 file changed

+130
-21
lines changed

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

Lines changed: 130 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,10 @@ sealed interface TimeOffset {
181181

182182
data class HhMm(val hours: Int, val minutes: Int, val sign: Sign) : TimeOffset {
183183
init {
184-
require(hours in 0..18) { "invalid hours: $hours (must be in the closed range 0..23)" }
185-
require(minutes in 0..59) { "invalid minutes: $minutes (must be in the closed range 0..59)" }
184+
require(hours in validHours) { "invalid hours: $hours (must be in the closed range 0..23)" }
185+
require(minutes in validMinutes) {
186+
"invalid minutes: $minutes (must be in the closed range 0..59)"
187+
}
186188
require(hours != 18 || minutes == 0) { "invalid minutes: $minutes (must be 0 when hours=18)" }
187189
}
188190

@@ -196,15 +198,40 @@ sealed interface TimeOffset {
196198
append("$minutes".padStart(2, '0'))
197199
}
198200

201+
fun toSeconds(): Int {
202+
val absValue = hours + (minutes * 60)
203+
return when (sign) {
204+
Sign.Positive -> absValue
205+
Sign.Negative -> -absValue
206+
}
207+
}
208+
199209
override fun toString() =
200210
"HhMm(hours=$hours, minutes=$minutes, sign=$sign, " +
201211
"zoneOffset=$zoneOffset, rfc3339String=$rfc3339String)"
202212

213+
operator fun compareTo(other: HhMm): Int = toSeconds() - other.toSeconds()
214+
203215
@Suppress("unused")
204216
enum class Sign(val char: Char, val multiplier: Int) {
205217
Positive('+', 1),
206218
Negative('-', -1),
207219
}
220+
221+
companion object {
222+
val validHours = 0..18
223+
val validMinutes = 0..59
224+
val maxSeconds: Int = 18 * 60
225+
226+
fun forSeconds(seconds: Int, sign: Sign): HhMm {
227+
require(seconds in 0..maxSeconds) {
228+
"invalid seconds: $seconds (must be between 0 and $maxSeconds, inclusive)"
229+
}
230+
val hours = seconds / 60
231+
val minutes = seconds - (hours * 60)
232+
return HhMm(hours = hours, minutes = minutes, sign = sign)
233+
}
234+
}
208235
}
209236
}
210237

@@ -219,7 +246,6 @@ object JavaTimeArbs {
219246
val minuteArb = minute()
220247
val secondArb = second()
221248
val nanosecondArb = nanosecond().orNull(nullProbability = 0.15)
222-
val timeOffsetArb = timeOffset()
223249

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

235309
val instant =
236310
OffsetDateTime.of(
@@ -245,14 +319,24 @@ object JavaTimeArbs {
245319
)
246320
.toInstant()
247321

248-
// The valid range below was copied from:
249-
// com.google.firebase.Timestamp.Timestamp.validateRange()
250-
require(instant.epochSecond in -62_135_596_800 until 253_402_300_800) {
322+
require(instant.epochSecond >= validEpochSecondRange.first) {
251323
"internal error weppxzqj2y: " +
252-
"instant.epochSecond out of range: ${instant.epochSecond} (" +
253-
"year=$year, month=$month, day=$day, " +
324+
"instant.epochSecond out of range by " +
325+
"${validEpochSecondRange.first - instant.epochSecond}: ${instant.epochSecond} (" +
326+
"validEpochSecondRange.first=${validEpochSecondRange.first}, "
327+
"year=$year, month=$month, day=$day, " +
254328
"hour=$hour, minute=$minute, second=$second, " +
255-
"nanosecond=$nanosecond timeOffset=$timeOffset)"
329+
"nanosecond=$nanosecond timeOffset=$timeOffset, " +
330+
"minTimeZoneOffset=$minTimeZoneOffset, maxTimeZoneOffset=$maxTimeZoneOffset)"
331+
}
332+
require(instant.epochSecond <= validEpochSecondRange.last) {
333+
"internal error yxga5xy9bm: " +
334+
"instant.epochSecond out of range by " +
335+
"${instant.epochSecond - validEpochSecondRange.last}: ${instant.epochSecond} (" +
336+
"validEpochSecondRange.last=${validEpochSecondRange.last}, " +
337+
"year=$year, month=$month, day=$day, " +
338+
"nanosecond=$nanosecond timeOffset=$timeOffset, " +
339+
"minTimeZoneOffset=$minTimeZoneOffset, maxTimeZoneOffset=$maxTimeZoneOffset)"
256340
}
257341

258342
val string = buildString {
@@ -282,7 +366,10 @@ object JavaTimeArbs {
282366
}
283367
}
284368

285-
fun timeOffset(): Arb<TimeOffset> = Arb.choice(timeOffsetUtc(), timeOffsetHhMm())
369+
fun timeOffset(
370+
min: TimeOffset.HhMm?,
371+
max: TimeOffset.HhMm?,
372+
): Arb<TimeOffset> = Arb.choice(timeOffsetUtc(), timeOffsetHhMm(min = min, max = max))
286373

287374
fun timeOffsetUtc(
288375
case: Arb<TimeOffset.Utc.Case> = Arb.enum(),
@@ -292,20 +379,42 @@ object JavaTimeArbs {
292379
sign: Arb<TimeOffset.HhMm.Sign> = Arb.enum(),
293380
hour: Arb<Int> = Arb.positiveIntWithUniformNumDigitsProbability(0..18),
294381
minute: Arb<Int> = minute(),
295-
): Arb<TimeOffset.HhMm> =
296-
arbitrary(
382+
min: TimeOffset.HhMm?,
383+
max: TimeOffset.HhMm?,
384+
): Arb<TimeOffset.HhMm> {
385+
require(min === null || max === null || min.toSeconds() < max.toSeconds()) {
386+
"min must be strictly less than max, but got: " +
387+
"min=$min (${min!!.toSeconds()} seconds), " +
388+
"max=$max (${max!!.toSeconds()} seconds), " +
389+
"a difference of ${min.toSeconds() - max.toSeconds()} seconds"
390+
}
391+
392+
return arbitrary(
297393
edgecases =
298394
listOf(
299-
TimeOffset.HhMm(hours = 0, minutes = 0, sign = TimeOffset.HhMm.Sign.Positive),
300-
TimeOffset.HhMm(hours = 0, minutes = 0, sign = TimeOffset.HhMm.Sign.Negative),
301-
TimeOffset.HhMm(hours = 17, minutes = 59, sign = TimeOffset.HhMm.Sign.Positive),
302-
TimeOffset.HhMm(hours = 17, minutes = 59, sign = TimeOffset.HhMm.Sign.Negative),
303-
TimeOffset.HhMm(hours = 18, minutes = 0, sign = TimeOffset.HhMm.Sign.Positive),
304-
TimeOffset.HhMm(hours = 18, minutes = 0, sign = TimeOffset.HhMm.Sign.Negative),
305-
)
395+
TimeOffset.HhMm(hours = 0, minutes = 0, sign = TimeOffset.HhMm.Sign.Positive),
396+
TimeOffset.HhMm(hours = 0, minutes = 0, sign = TimeOffset.HhMm.Sign.Negative),
397+
TimeOffset.HhMm(hours = 17, minutes = 59, sign = TimeOffset.HhMm.Sign.Positive),
398+
TimeOffset.HhMm(hours = 17, minutes = 59, sign = TimeOffset.HhMm.Sign.Negative),
399+
TimeOffset.HhMm(hours = 18, minutes = 0, sign = TimeOffset.HhMm.Sign.Positive),
400+
TimeOffset.HhMm(hours = 18, minutes = 0, sign = TimeOffset.HhMm.Sign.Negative),
401+
)
402+
.filter { (min === null || it >= min) || (max === null || it <= max) }
306403
) {
307-
TimeOffset.HhMm(hours = hour.bind(), minutes = minute.bind(), sign = sign.bind())
404+
var count = 0
405+
var hhmm: TimeOffset.HhMm
406+
while (true) {
407+
count++
408+
hhmm = TimeOffset.HhMm(hours = hour.bind(), minutes = minute.bind(), sign = sign.bind())
409+
if ((min === null || hhmm >= min) && (max === null || hhmm <= max)) {
410+
break
411+
} else if (count > 1000) {
412+
throw Exception("internal error j878fp4gmr: exhausted attempts to generate HhMm")
413+
}
414+
}
415+
hhmm
308416
}
417+
}
309418

310419
fun year(): Arb<Int> = Arb.int(MIN_YEAR..MAX_YEAR)
311420

0 commit comments

Comments
 (0)