Skip to content

Commit 7d2764a

Browse files
Support obtaining the system timezone on old Debian-based distributions (#503)
- Add fallback to read /etc/timezone when the symlink at /etc/localtime isn’t used. - Validate against the corresponding zoneinfo file. - Enhance test coverage using kotlin.test Fixes #430
1 parent 5a1b10e commit 7d2764a

File tree

17 files changed

+124
-6
lines changed

17 files changed

+124
-6
lines changed

core/linux/src/internal/TimeZoneNative.kt

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
package kotlinx.datetime.internal
77

8+
import kotlinx.cinterop.ExperimentalForeignApi
9+
import kotlinx.cinterop.toKString
810
import kotlinx.datetime.IllegalTimeZoneException
911
import kotlinx.datetime.TimeZone
1012

@@ -16,10 +18,45 @@ internal actual fun getAvailableZoneIds(): Set<String> =
1618

1719
private val tzdb = runCatching { TzdbOnFilesystem() }
1820

21+
// This workaround is needed for Debian versions Etch (4.0) - Jessie (8.0), where the timezone data is organized differently.
22+
// See: https://github.com/Kotlin/kotlinx-datetime/issues/430
23+
@OptIn(ExperimentalForeignApi::class)
24+
private fun getTimezoneFromEtcTimezone(): String? {
25+
val timezoneContent = Path.fromString("${systemTimezoneSearchRoot}etc/timezone").readBytes()?.toKString()?.trim() ?: return null
26+
val zoneId = chaseSymlinks("${systemTimezoneSearchRoot}usr/share/zoneinfo/$timezoneContent")
27+
?.splitTimeZonePath()?.second?.toString()
28+
?: return null
29+
30+
val zoneInfoBytes = Path.fromString("${systemTimezoneSearchRoot}usr/share/zoneinfo/$zoneId").readBytes() ?: return null
31+
val localtimeBytes = Path.fromString("${systemTimezoneSearchRoot}etc/localtime").readBytes() ?: return null
32+
33+
if (!localtimeBytes.contentEquals(zoneInfoBytes)) {
34+
val displayTimezone = when (timezoneContent) {
35+
zoneId -> "'$zoneId'"
36+
else -> "'$timezoneContent' (resolved to '$zoneId')"
37+
}
38+
throw IllegalTimeZoneException(
39+
"Timezone mismatch: ${systemTimezoneSearchRoot}etc/timezone specifies $displayTimezone " +
40+
"but ${systemTimezoneSearchRoot}etc/localtime content differs from ${systemTimezoneSearchRoot}usr/share/zoneinfo/$zoneId"
41+
)
42+
}
43+
44+
return zoneId
45+
}
46+
1947
internal actual fun currentSystemDefaultZone(): Pair<String, TimeZone?> {
20-
// according to https://www.man7.org/linux/man-pages/man5/localtime.5.html, when there is no symlink, UTC is used
48+
// According to https://www.man7.org/linux/man-pages/man5/localtime.5.html, UTC is used when /etc/localtime is missing.
49+
// If /etc/localtime exists but isn't a symlink, we check if it's a copy of a timezone file by examining /etc/timezone
50+
// (which is a Debian-specific approach used in older distributions).
2151
val zonePath = currentSystemTimeZonePath ?: return "Z" to null
22-
val zoneId = zonePath.splitTimeZonePath()?.second?.toString()
23-
?: throw IllegalTimeZoneException("Could not determine the timezone ID that `$zonePath` corresponds to")
24-
return zoneId to null
25-
}
52+
53+
zonePath.splitTimeZonePath()?.second?.toString()?.let { zoneId ->
54+
return zoneId to null
55+
}
56+
57+
getTimezoneFromEtcTimezone()?.let { zoneId ->
58+
return zoneId to null
59+
}
60+
61+
throw IllegalTimeZoneException("Could not determine the timezone ID that `$zonePath` corresponds to")
62+
}

core/linux/test/TimeZoneNativeTest.kt

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright 2019-2025 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.test
7+
8+
import kotlinx.datetime.IllegalTimeZoneException
9+
import kotlinx.datetime.TimeZone
10+
import kotlinx.datetime.internal.systemTimezoneSearchRoot
11+
import kotlin.test.Test
12+
import kotlin.test.assertEquals
13+
import kotlin.test.assertFailsWith
14+
import kotlin.test.assertTrue
15+
16+
class TimeZoneNativeTest {
17+
18+
@Test
19+
fun correctSymlinkTest() = withFakeRoot("${RESOURCES}correct-symlink/") {
20+
val tz = TimeZone.currentSystemDefault()
21+
assertEquals(TimeZone.of("Europe/Oslo"), tz)
22+
}
23+
24+
@Test
25+
fun timezoneFileAgreesWithLocaltimeContentsTest() = withFakeRoot("${RESOURCES}timezone-file-agrees-with-localtime-contents/") {
26+
val tz = TimeZone.currentSystemDefault()
27+
assertEquals(TimeZone.of("Europe/Oslo"), tz)
28+
}
29+
30+
@Test
31+
fun fallbackToUTCWhenNoLocaltimeTest() = withFakeRoot("${RESOURCES}fallback-to-utc-when-no-localtime/") {
32+
val tz = TimeZone.currentSystemDefault()
33+
assertEquals(TimeZone.UTC, tz)
34+
}
35+
36+
@Test
37+
fun missingTimezoneWhenLocaltimeIsNotSymlinkTest() = withFakeRoot("${RESOURCES}missing-timezone-when-localtime-is-not-symlink/") {
38+
assertFailsWith<IllegalTimeZoneException> {
39+
TimeZone.currentSystemDefault()
40+
}
41+
}
42+
43+
@Test
44+
fun nonExistentTimezoneInTimezoneFileTest() = withFakeRoot("${RESOURCES}non-existent-timezone-in-timezone-file/") {
45+
assertFailsWith<IllegalTimeZoneException> {
46+
TimeZone.currentSystemDefault()
47+
}
48+
}
49+
50+
@Test
51+
fun timezoneFileDisagreesWithLocaltimeContentsTest() = withFakeRoot("${RESOURCES}timezone-file-disagrees-with-localtime-contents/") {
52+
val exception = assertFailsWith<IllegalTimeZoneException> {
53+
TimeZone.currentSystemDefault()
54+
}
55+
56+
assertTrue(
57+
exception.message?.contains("Europe/Oslo") == true,
58+
"Exception message does not contain 'Europe/Oslo' as expected"
59+
)
60+
}
61+
62+
companion object {
63+
const val RESOURCES = "./linux/test/time-zone-native-test-resources/"
64+
65+
private fun withFakeRoot(fakeRoot: String, action: () -> Unit) {
66+
val defaultRoot = systemTimezoneSearchRoot
67+
systemTimezoneSearchRoot = fakeRoot
68+
try {
69+
action()
70+
} finally {
71+
systemTimezoneSearchRoot = defaultRoot
72+
}
73+
}
74+
}
75+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../usr/share/zoneinfo/Europe/Oslo

core/linux/test/time-zone-native-test-resources/fallback-to-utc-when-no-localtime/etc/.keep

Whitespace-only changes.

core/linux/test/time-zone-native-test-resources/fallback-to-utc-when-no-localtime/usr/share/zoneinfo/Europe/.keep

Whitespace-only changes.

core/linux/test/time-zone-native-test-resources/missing-timezone-when-localtime-is-not-symlink/etc/localtime

Whitespace-only changes.

core/linux/test/time-zone-native-test-resources/non-existent-timezone-in-timezone-file/etc/localtime

Whitespace-only changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
incorrect/timezone
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Europe/Oslo
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Europe/Oslo

core/tzdbOnFilesystem/src/internal/TzdbOnFilesystem.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ internal fun tzdbPaths(defaultTzdbPath: Path?) = sequence {
4747
currentSystemTimeZonePath?.splitTimeZonePath()?.first?.let { yield(it) }
4848
}
4949

50-
internal val currentSystemTimeZonePath get() = chaseSymlinks("/etc/localtime")
50+
internal var systemTimezoneSearchRoot: String = "/"
51+
52+
internal val currentSystemTimeZonePath get() = chaseSymlinks("${systemTimezoneSearchRoot}etc/localtime")
5153

5254
/**
5355
* Given a path like `/usr/share/zoneinfo/Europe/Berlin`, produces `/usr/share/zoneinfo to Europe/Berlin`.

0 commit comments

Comments
 (0)