diff --git a/core/androidNative/src/internal/TzdbBionic.kt b/core/androidNative/src/internal/TzdbBionic.kt index d889060bf..63bd29e30 100644 --- a/core/androidNative/src/internal/TzdbBionic.kt +++ b/core/androidNative/src/internal/TzdbBionic.kt @@ -26,9 +26,8 @@ internal fun TzdbBionic(): TimeZoneDatabase = TzdbBionic(buildMap + tabPaths.any { path.containsFile(it) } } ?: throw IllegalStateException("Could not find the path to the timezone database") - override fun rulesForId(id: String): TimeZoneRules = - readTzFile(tzdbPath.resolve(Path.fromString(id)).readBytes()).toTimeZoneRules() + override fun rulesForId(id: String): TimeZoneRules { + val idAsPath = Path.fromString(id) + require(!idAsPath.isAbsolute) { "Timezone ID '$idAsPath' must not begin with a '/'" } + require(idAsPath.components.none { it == ".." }) { "'$idAsPath' must not contain '..' as a component" } + val file = Path(tzdbPath.isAbsolute, tzdbPath.components + idAsPath.components) + val contents = file.readBytes() ?: throw RuntimeException("File '$file' not found") + return readTzFile(contents).toTimeZoneRules() + } override fun availableTimeZoneIds(): Set = buildSet { - tzdbPath.traverseDirectory(exclude = tzdbUnneededFiles) { add(it.toString()) } + tzdbPath.tryTraverseDirectory(exclude = tzdbUnneededFiles) { add(it.toString()) } } } +// taken from https://github.com/tzinfo/tzinfo/blob/9953fc092424d55deaea2dcdf6279943f3495724/lib/tzinfo/data_sources/zoneinfo_data_source.rb#L354 +private val tabPaths = listOf("zone1970.tab", "zone.tab", "tab/zone_sun.tab") + /** The files that sometimes lie in the `zoneinfo` directory but aren't actually time zones. */ private val tzdbUnneededFiles = setOf( // taken from https://github.com/tzinfo/tzinfo/blob/9953fc092424d55deaea2dcdf6279943f3495724/lib/tzinfo/data_sources/zoneinfo_data_source.rb#L88C29-L97C21 @@ -51,7 +60,7 @@ internal fun tzdbPaths(defaultTzdbPath: Path?) = sequence { // taken from https://github.com/HowardHinnant/date/blob/ab37c362e35267d6dee02cb47760f9e9c669d3be/src/tz.cpp#L3951-L3952 internal fun pathToSystemDefault(): Pair? { - val info = Path(true, listOf("etc", "localtime")).chaseSymlinks() + val info = chaseSymlinks("/etc/localtime") ?: return null val i = info.components.indexOf("zoneinfo") if (!info.isAbsolute || i == -1 || i == info.components.size - 1) return null return Pair( diff --git a/core/tzdbOnFilesystem/src/internal/filesystem.kt b/core/tzdbOnFilesystem/src/internal/filesystem.kt index 3d48bfbbb..755c1cdb4 100644 --- a/core/tzdbOnFilesystem/src/internal/filesystem.kt +++ b/core/tzdbOnFilesystem/src/internal/filesystem.kt @@ -9,18 +9,21 @@ package kotlinx.datetime.internal import kotlinx.cinterop.* import platform.posix.* -internal fun Path.chaseSymlinks(maxDepth: Int = 100): Path { - var realPath = this - var depth = maxDepth - while (true) { - realPath = realPath.readLink() ?: break - if (depth-- == 0) throw RuntimeException("Too many levels of symbolic links") - } - return realPath +internal fun chaseSymlinks(name: String): Path? = memScoped { + val buffer = allocArray(PATH_MAX) + realpath(name, buffer)?.let { Path.fromString(it.toKString()) } } -internal fun Path.traverseDirectory(exclude: Set = emptySet(), stripLeadingComponents: Int = this.components.size, actionOnFile: (Path) -> Unit) { - val handler = opendir(this.toString()) ?: return +internal fun Path.containsFile(file: String): Boolean = access("$this/$file", F_OK) == 0 + +internal fun Path.tryTraverseDirectory( + exclude: Set = emptySet(), + stripLeadingComponents: Int = this.components.size, + maxDepth: Int = 100, + actionOnFile: (Path) -> Unit +): Boolean { + if (maxDepth <= 0) throw IllegalStateException("Max depth reached: $this") + val handler = opendir(this.toString()) ?: return false try { while (true) { val entry = readdir(handler) ?: break @@ -28,16 +31,15 @@ internal fun Path.traverseDirectory(exclude: Set = emptySet(), stripLead if (name == "." || name == "..") continue if (name in exclude) continue val path = Path(isAbsolute, components + name) - val info = path.check() ?: continue // skip broken symlinks - if (info.isDirectory) { - if (!info.isSymlink) { - path.traverseDirectory(exclude, stripLeadingComponents, actionOnFile) - } - } else { + val isDirectory = path.tryTraverseDirectory( + exclude, stripLeadingComponents, maxDepth = maxDepth - 1, actionOnFile + ) + if (!isDirectory) { actionOnFile(Path(false, path.components.drop(stripLeadingComponents))) } } } finally { closedir(handler) } + return true } diff --git a/core/tzdbOnFilesystem/test/TimeZoneRulesCompleteTest.kt b/core/tzdbOnFilesystem/test/TimeZoneRulesCompleteTest.kt index 3aa52da47..43437708a 100644 --- a/core/tzdbOnFilesystem/test/TimeZoneRulesCompleteTest.kt +++ b/core/tzdbOnFilesystem/test/TimeZoneRulesCompleteTest.kt @@ -16,12 +16,10 @@ class TimeZoneRulesCompleteTest { @OptIn(ExperimentalEncodingApi::class) @Test fun iterateOverAllTimezones() { - val root = Path.fromString("/usr/share/zoneinfo") - val tzdb = TzdbOnFilesystem(root) + val tzdb = TzdbOnFilesystem() for (id in tzdb.availableTimeZoneIds()) { - val file = root.resolve(Path.fromString(id)) val rules = tzdb.rulesForId(id) - runUnixCommand("env LOCALE=C zdump -V $file").windowed(size = 2, step = 2).forEach { (line1, line2) -> + runUnixCommand("env LOCALE=C zdump -V ${tzdb.tzdbPath}/$id").windowed(size = 2, step = 2).forEach { (line1, line2) -> val beforeTransition = parseZdumpLine(line1) val afterTransition = parseZdumpLine(line2) try { @@ -51,7 +49,7 @@ class TimeZoneRulesCompleteTest { } catch (e: Throwable) { println(beforeTransition) println(afterTransition) - println(Base64.encode(file.readBytes())) + println(Base64.encode(Path.fromString("${tzdb.tzdbPath}/$id").readBytes()!!)) throw e } } diff --git a/core/tzfile/src/internal/filesystem.kt b/core/tzfile/src/internal/filesystem.kt index 090debff7..9ec93f01f 100644 --- a/core/tzfile/src/internal/filesystem.kt +++ b/core/tzfile/src/internal/filesystem.kt @@ -10,29 +10,6 @@ import kotlinx.cinterop.* import platform.posix.* internal class Path(val isAbsolute: Boolean, val components: List) { - fun check(): PathInfo? = memScoped { - val stat = alloc() - val err = stat(this@Path.toString(), stat.ptr) - if (err != 0) return null - object : PathInfo { - override val isDirectory: Boolean = stat.st_mode.toInt() and S_IFMT == S_IFDIR // `inode(7)`, S_ISDIR - override val isSymlink: Boolean = stat.st_mode.toInt() and S_IFMT == S_IFLNK // `inode(7)`, S_ISLNK - } - } - - fun readLink(): Path? = memScoped { - val buffer = allocArray(PATH_MAX) - val err = readlink(this@Path.toString(), buffer, PATH_MAX.convert()) - if (err == (-1).convert()) return null - buffer[err] = 0 - fromString(buffer.toKString()) - } - - fun resolve(other: Path): Path = when { - other.isAbsolute -> other - else -> Path(isAbsolute, components + other.components) - } - override fun toString(): String = buildString { if (isAbsolute) append("/") if (components.isNotEmpty()) { @@ -53,14 +30,8 @@ internal class Path(val isAbsolute: Boolean, val components: List) { } } -// `stat(2)` lists the other available fields -internal interface PathInfo { - val isDirectory: Boolean - val isSymlink: Boolean -} - -internal fun Path.readBytes(): ByteArray { - val handler = fopen(this.toString(), "rb") ?: throw RuntimeException("Cannot open file $this") +internal fun Path.readBytes(): ByteArray? { + val handler = fopen(this.toString(), "rb") ?: return null try { var err = fseek(handler, 0, SEEK_END) if (err == -1) throw RuntimeException("Cannot jump to the end of $this: $errnoString")