diff --git a/okio/src/commonTest/kotlin/okio/OkioTesting.kt b/okio/src/commonTest/kotlin/okio/OkioTesting.kt index bc495e99e3..62ac1ce528 100644 --- a/okio/src/commonTest/kotlin/okio/OkioTesting.kt +++ b/okio/src/commonTest/kotlin/okio/OkioTesting.kt @@ -102,3 +102,5 @@ expect fun assertRelativeToFails( b: Path, sameAsNio: Boolean = true, ): IllegalArgumentException + +expect fun withUtc(block: () -> T): T diff --git a/okio/src/jvmMain/kotlin/okio/internal/-ZlibJvm.kt b/okio/src/jvmMain/kotlin/okio/internal/-ZlibJvm.kt index 791af635fa..d0e8d23cc8 100644 --- a/okio/src/jvmMain/kotlin/okio/internal/-ZlibJvm.kt +++ b/okio/src/jvmMain/kotlin/okio/internal/-ZlibJvm.kt @@ -16,6 +16,23 @@ */ package okio.internal +import java.util.Calendar +import java.util.GregorianCalendar + internal actual val DEFAULT_COMPRESSION = java.util.zip.Deflater.DEFAULT_COMPRESSION internal actual typealias CRC32 = java.util.zip.CRC32 + +internal actual fun datePartsToEpochMillis( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Int, +): Long { + val calendar = GregorianCalendar() + calendar.set(Calendar.MILLISECOND, 0) + calendar.set(year, month - 1, day, hour, minute, second) + return calendar.time.time +} diff --git a/okio/src/jvmMain/kotlin/okio/internal/ZipFiles.kt b/okio/src/jvmMain/kotlin/okio/internal/ZipFiles.kt index 02b6a8489a..f65e82b32e 100644 --- a/okio/src/jvmMain/kotlin/okio/internal/ZipFiles.kt +++ b/okio/src/jvmMain/kotlin/okio/internal/ZipFiles.kt @@ -16,8 +16,6 @@ */ package okio.internal -import java.util.Calendar -import java.util.GregorianCalendar import okio.BufferedSource import okio.FileMetadata import okio.FileSystem @@ -435,17 +433,14 @@ private fun dosDateTimeToEpochMillis(date: Int, time: Int): Long? { return null } - // Note that this inherits the local time zone. - val cal = GregorianCalendar() - cal.set(Calendar.MILLISECOND, 0) - val year = 1980 + (date shr 9 and 0x7f) - val month = date shr 5 and 0xf - val day = date and 0x1f - val hour = time shr 11 and 0x1f - val minute = time shr 5 and 0x3f - val second = time and 0x1f shl 1 - cal.set(year, month - 1, day, hour, minute, second) - return cal.time.time + return datePartsToEpochMillis( + year = 1980 + (date shr 9 and 0x7f), + month = date shr 5 and 0xf, + day = date and 0x1f, + hour = time shr 11 and 0x1f, + minute = time shr 5 and 0x3f, + second = time and 0x1f shl 1, + ) } private class EocdRecord( diff --git a/okio/src/jvmTest/kotlin/okio/JvmTesting.kt b/okio/src/jvmTest/kotlin/okio/JvmTesting.kt index e6b091d775..c135095f2d 100644 --- a/okio/src/jvmTest/kotlin/okio/JvmTesting.kt +++ b/okio/src/jvmTest/kotlin/okio/JvmTesting.kt @@ -15,6 +15,7 @@ */ package okio +import java.util.TimeZone import kotlin.test.assertEquals import kotlin.test.assertFailsWith import okio.Path.Companion.toOkioPath @@ -52,3 +53,13 @@ actual fun assertRelativeToFails( // Return okio. return assertFailsWith { b.relativeTo(a) } } + +actual fun withUtc(block: () -> T): T { + val original = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + try { + return block() + } finally { + TimeZone.setDefault(original) + } +} diff --git a/okio/src/nativeMain/kotlin/okio/internal/-ZlibNative.kt b/okio/src/nativeMain/kotlin/okio/internal/-ZlibNative.kt index 5bcc3f53b0..a7ee44e065 100644 --- a/okio/src/nativeMain/kotlin/okio/internal/-ZlibNative.kt +++ b/okio/src/nativeMain/kotlin/okio/internal/-ZlibNative.kt @@ -17,3 +17,62 @@ package okio.internal internal actual val DEFAULT_COMPRESSION: Int = platform.zlib.Z_DEFAULT_COMPRESSION + +/** + * Roll our own date math because Kotlin doesn't include a built-in date math API, and the + * kotlinx.datetime library doesn't offer a stable at this time. + * + * Also, we don't necessarily want to take on that dependency for Okio. + * + * This implementation assumes UTC. + * + * This code is broken for years before 1970. It doesn't implement subtraction for leap years. + * + * This code is broken for out-of-range values. For example, it doesn't correctly implement leap + * year offsets when the month is -24 or when the day is -365. + */ +internal actual fun datePartsToEpochMillis( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Int, +): Long { + // Make sure month is in 1..12, adding or subtracting years as necessary. + val rawMonth = month + val month = (month - 1).mod(12) + 1 + val year = year + (rawMonth - month) / 12 + + // Start with the cumulative number of days elapsed preceding the current year. + var dayCount = (year - 1970) * 365L + + // Adjust by leap years. Years that divide 4 are leap years, unless they divide 100 but not 400. + val leapYear = if (month > 2) year else year - 1 + dayCount += (leapYear - 1968) / 4 - (leapYear - 1900) / 100 + (leapYear - 1600) / 400 + + // Add the cumulative number of days elapsed preceding the current month. + dayCount += when (month) { + 1 -> 0 + 2 -> 31 + 3 -> 59 + 4 -> 90 + 5 -> 120 + 6 -> 151 + 7 -> 181 + 8 -> 212 + 9 -> 243 + 10 -> 273 + 11 -> 304 + else -> 334 + } + + // Add the cumulative number of days that precede the current day. + dayCount += (day - 1) + + // Add hours + minutes + seconds for the current day. + val hourCount = dayCount * 24 + hour + val minuteCount = hourCount * 60 + minute + val secondCount = minuteCount * 60 + second + return secondCount * 1_000L +} diff --git a/okio/src/nonJvmTest/kotlin/okio/NonJvmTesting.kt b/okio/src/nonJvmTest/kotlin/okio/NonJvmTesting.kt index a5e9a780f2..0483999b20 100644 --- a/okio/src/nonJvmTest/kotlin/okio/NonJvmTesting.kt +++ b/okio/src/nonJvmTest/kotlin/okio/NonJvmTesting.kt @@ -36,3 +36,7 @@ actual fun assertRelativeToFails( ): IllegalArgumentException { return assertFailsWith { b.relativeTo(a) } } + +actual fun withUtc(block: () -> T): T { + return block() +} diff --git a/okio/src/zlibMain/kotlin/okio/internal/-Zlib.kt b/okio/src/zlibMain/kotlin/okio/internal/-Zlib.kt index 6c42210569..b31c9e1553 100644 --- a/okio/src/zlibMain/kotlin/okio/internal/-Zlib.kt +++ b/okio/src/zlibMain/kotlin/okio/internal/-Zlib.kt @@ -17,3 +17,22 @@ package okio.internal internal expect val DEFAULT_COMPRESSION: Int + +/** + * Note that this inherits the local time zone. + * + * @param year such as 1970 or 2024 + * @param month a value in the range 1 (January) through 12 (December). + * @param day a value in the range 1 through 31. + * @param hour a value in the range 0 through 23. + * @param minute a value in the range 0 through 59. + * @param second a value in the range 0 through 59. + */ +internal expect fun datePartsToEpochMillis( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Int, +): Long diff --git a/okio/src/zlibTest/kotlin/okio/internal/DatePartsToEpochMillisTest.kt b/okio/src/zlibTest/kotlin/okio/internal/DatePartsToEpochMillisTest.kt new file mode 100644 index 0000000000..203404989f --- /dev/null +++ b/okio/src/zlibTest/kotlin/okio/internal/DatePartsToEpochMillisTest.kt @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okio.internal + +import kotlin.test.Test +import kotlin.test.assertEquals +import okio.withUtc + +class DatePartsToEpochMillisTest { + /** + * Test every day from 1970-01-01 (epochMillis = 0) until 2200-01-01. Note that this includes the + * full range of ZIP DOS dates (1980-01-01 until 2107-12-31). + */ + @Test + fun everySingleDay() { + val dateTester = DateTester() + while (dateTester.year < 2200) { + dateTester.addDay() + dateTester.check() + } + } + + /** Test the boundaries of the ZIP DOS date format. */ + @Test + fun dosDateRange() { + assertEquals( + (365 * 10 + 2) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1980, month = 1, day = 1), + ) + assertEquals( + (365 * 138 + 33) * (24 * 60 * 60 * 1000L) - 1_000L, + datePartsToEpochMillisUtc( + year = 2107, + month = 12, + day = 31, + hour = 23, + minute = 59, + second = 59, + ), + ) + } + + @Test + fun monthOutOfBounds() { + // Month -21 is the same as March, 22 months ago. + assertEquals( + (-365 + -365 + 31 + 28) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(month = -21, day = 1), + ) + + // Month -12 is the same as December, 13 months ago. + assertEquals( + (-365 + -31) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = -12, day = 1), + ) + + // Month -11 is the same as January, 12 months ago. + assertEquals( + -365 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = -11, day = 1), + ) + + // Month -1 is the same as November, 2 months ago. + assertEquals( + (-31 + -30) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = -1, day = 1), + ) + + // Month 0 is the same as December, 1 month ago. + assertEquals( + -31 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 0, day = 1), + ) + + // Month 13 is the same as January, 12 months from now. + assertEquals( + 365 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 13, day = 1), + ) + + // Month 24 is the same as December, 23 months from now + assertEquals( + (365 + 365 - 31) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 24, day = 1), + ) + + // Month 25 is the same as January, 24 months from now + assertEquals( + (365 + 365) * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 25, day = 1), + ) + } + + @Test + fun dayOutOfBounds() { + // Day -364 is the same as January 1 of the previous year. + assertEquals( + -365 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 1, day = -364), + ) + + // Day -1 is the same as December 30 of the previous year. + assertEquals( + -2 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 1, day = -1), + ) + + // Day 0 is the same as December 31 of the previous year. + assertEquals( + -1 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 1, day = 0), + ) + + // Day 32 is the same as February 1. + assertEquals( + 31 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 1, day = 32), + ) + + // Day 33 is the same as February 2. + assertEquals( + 32 * (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(year = 1970, month = 1, day = 33), + ) + } + + @Test + fun hourOutOfBounds() { + assertEquals( + (-24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(hour = -24), + ) + assertEquals( + (-1 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(hour = -1), + ) + assertEquals( + (24 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(hour = 24), + ) + assertEquals( + (25 * 60 * 60 * 1000L), + datePartsToEpochMillisUtc(hour = 25), + ) + } + + @Test + fun minuteOutOfBounds() { + assertEquals( + (-1 * 60 * 1000L), + datePartsToEpochMillisUtc(minute = -1), + ) + assertEquals( + (60 * 60 * 1000L), + datePartsToEpochMillisUtc(minute = 60), + ) + assertEquals( + (61 * 60 * 1000L), + datePartsToEpochMillisUtc(minute = 61), + ) + } + + @Test + fun secondOutOfBounds() { + assertEquals( + (-1 * 1000L), + datePartsToEpochMillisUtc(hour = 0, second = -1), + ) + assertEquals( + (60 * 1000L), + datePartsToEpochMillisUtc(hour = 0, second = 60), + ) + assertEquals( + (61 * 1000L), + datePartsToEpochMillisUtc(hour = 0, second = 61), + ) + } + + private class DateTester { + var epochMillis = 0L + var year = 1970 + var month = 1 + var day = 1 + + fun addDay() { + day++ + epochMillis += 24L * 60 * 60 * 1000 + + val monthSize = when (month) { + 1 -> 31 + 2 -> { + when { + year % 400 == 0 -> 29 + year % 100 == 0 -> 28 + year % 4 == 0 -> 29 + else -> 28 + } + } + + 3 -> 31 + 4 -> 30 + 5 -> 31 + 6 -> 30 + 7 -> 31 + 8 -> 31 + 9 -> 30 + 10 -> 31 + 11 -> 30 + 12 -> 31 + else -> error("unexpected month $month") + } + + if (day > monthSize) { + day -= monthSize + month++ + if (month > 12) { + month -= 12 + year++ + } + } + } + + fun check() { + assertEquals( + expected = epochMillis, + actual = datePartsToEpochMillisUtc( + year = year, + month = month, + day = day, + ), + message = "y=$year m=$month d=$day", + ) + } + } +} + +fun datePartsToEpochMillisUtc( + year: Int = 1970, + month: Int = 1, + day: Int = 1, + hour: Int = 0, + minute: Int = 0, + second: Int = 0, +): Long { + return withUtc { + datePartsToEpochMillis(year, month, day, hour, minute, second) + } +}