diff --git a/core/api/kotlinx-io-core.api b/core/api/kotlinx-io-core.api index dfb090038..c0a8fcba4 100644 --- a/core/api/kotlinx-io-core.api +++ b/core/api/kotlinx-io-core.api @@ -231,6 +231,7 @@ public final class kotlinx/io/files/Path { public final fun getParent ()Lkotlinx/io/files/Path; public fun hashCode ()I public final fun isAbsolute ()Z + public final fun normalized ()Lkotlinx/io/files/Path; public fun toString ()Ljava/lang/String; } diff --git a/core/common/src/files/Paths.kt b/core/common/src/files/Paths.kt index 63531a9c8..64f51ffdc 100644 --- a/core/common/src/files/Paths.kt +++ b/core/common/src/files/Paths.kt @@ -40,6 +40,12 @@ public expect class Path { */ public val isAbsolute: Boolean + /** + * Returns normalized version of this path where all `..` and `.` segments are resolved + * and all sequential path separators are collapsed. + */ + public fun normalized(): Path + /** * Returns a string representation of this path. * @@ -174,3 +180,51 @@ private fun removeTrailingSeparatorsWindows(suffixLength: Int, path: String): St } return path.substring(0, idx) } + +internal fun Path.normalizedInternal(preserveDrive: Boolean, vararg separators: Char): String { + var isAbs = isAbsolute + var stringRepresentation = toString() + var drive = "" + if (preserveDrive && stringRepresentation.length >= 2 && stringRepresentation[1] == ':') { + drive = stringRepresentation.substring(0, 2) + stringRepresentation = stringRepresentation.substring(2) + isAbs = stringRepresentation.isNotEmpty() && separators.contains(stringRepresentation.first()) + } + val parts = stringRepresentation.split(*separators) + val constructedPath = mutableListOf() + for (idx in parts.indices) { + when (val part = parts[idx]) { + "." -> continue + ".." -> if (isAbs) { + constructedPath.removeLastOrNull() + } else { + if (constructedPath.isEmpty() || constructedPath.last() == "..") { + constructedPath.add("..") + } else { + constructedPath.removeLast() + } + } + + else -> { + if (part.isNotEmpty()) { + constructedPath.add(part) + } + } + } + } + return buildString { + append(drive) + var skipFirstSeparator = true + if (isAbs) { + append(SystemPathSeparator) + } + for (segment in constructedPath) { + if (skipFirstSeparator) { + skipFirstSeparator = false + } else { + append(SystemPathSeparator) + } + append(segment) + } + } +} diff --git a/core/common/test/files/SmokeFileTest.kt b/core/common/test/files/SmokeFileTest.kt index 997095b6f..e06d0c0d5 100644 --- a/core/common/test/files/SmokeFileTest.kt +++ b/core/common/test/files/SmokeFileTest.kt @@ -443,6 +443,19 @@ class SmokeFileTest { source.close() // there should be no error } + @Test + fun pathNormalize() { + assertEquals(Path(""), Path("").normalized()) + assertEquals(Path("${SystemPathSeparator}a"), Path("/////////////a/").normalized()) + assertEquals(Path("..", "..", "e"), Path("a/b/../c/../d/../../../../e").normalized()) + assertEquals(Path("a"), Path("a/././././").normalized()) + + if (!isWindows) { + // On Windows, this path is usually considered relative + assertEquals(Path("${SystemPathSeparator}e"), Path("/a/b/../c/../d/../../../e").normalized()) + } + } + private fun constructAbsolutePath(vararg parts: String): String { return SystemPathSeparator.toString() + parts.joinToString(SystemPathSeparator.toString()) } diff --git a/core/common/test/files/SmokeFileTestWindows.kt b/core/common/test/files/SmokeFileTestWindows.kt index 23994807e..c381a7b40 100644 --- a/core/common/test/files/SmokeFileTestWindows.kt +++ b/core/common/test/files/SmokeFileTestWindows.kt @@ -42,4 +42,12 @@ class SmokeFileTestWindows { // this path could be transformed to use canonical separator on JVM assertEquals(Path("//").toString(), Path("//").toString()) } + + @Test + fun pathNormalize() { + if (!isWindows) return + assertEquals(Path("C:a", "b", "c", "d", "e"), Path("C:a/b\\\\\\//////c/d\\e").normalized()) + assertEquals(Path("C:$SystemPathSeparator"), Path("C:\\..\\..\\..\\").normalized()) + assertEquals(Path("C:..", "..", ".."), Path("C:..\\..\\..\\").normalized()) + } } diff --git a/core/common/test/files/UtilsTest.kt b/core/common/test/files/UtilsTest.kt index 496ef4239..c26dde205 100644 --- a/core/common/test/files/UtilsTest.kt +++ b/core/common/test/files/UtilsTest.kt @@ -52,4 +52,12 @@ class UtilsTest { assertEquals("C:\\", removeTrailingSeparatorsW("C:\\")) assertEquals("C:\\", removeTrailingSeparatorsW("C:\\/\\")) } + + @Test + fun normalizePathWithDrive() { + assertEquals("C:$SystemPathSeparator", + Path("C:\\..\\..\\..\\").normalizedInternal(true, WindowsPathSeparator, UnixPathSeparator)) + assertEquals("C:..$SystemPathSeparator..$SystemPathSeparator..", + Path("C:..\\..\\..\\").normalizedInternal(true, WindowsPathSeparator, UnixPathSeparator)) + } } diff --git a/core/jvm/src/files/PathsJvm.kt b/core/jvm/src/files/PathsJvm.kt index 2d26281e1..b44082cf2 100644 --- a/core/jvm/src/files/PathsJvm.kt +++ b/core/jvm/src/files/PathsJvm.kt @@ -19,6 +19,14 @@ public actual class Path internal constructor(internal val file: File) { public actual override fun toString(): String = file.toString() + // Don't use File.normalize here as it may work incorrectly for absolute paths: + // https://youtrack.jetbrains.com/issue/KT-48354 + public actual fun normalized(): Path = Path(path = if (isWindows) { + normalizedInternal(true, WindowsPathSeparator, UnixPathSeparator) + } else { + normalizedInternal(false, UnixPathSeparator) + }) + actual override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Path) return false diff --git a/core/native/src/files/PathsNative.kt b/core/native/src/files/PathsNative.kt index 9ac0cc3fb..5c53d4221 100644 --- a/core/native/src/files/PathsNative.kt +++ b/core/native/src/files/PathsNative.kt @@ -52,6 +52,12 @@ public actual class Path internal constructor( if (path.isEmpty() || path == SystemPathSeparator.toString()) return "" return basenameImpl(path) } + + public actual fun normalized(): Path = Path(path = if (isWindows) { + normalizedInternal(true, WindowsPathSeparator, UnixPathSeparator) + } else { + normalizedInternal(false, UnixPathSeparator) + }) } public actual val SystemPathSeparator: Char = UnixPathSeparator diff --git a/core/nodeFilesystemShared/src/files/PathsNodeJs.kt b/core/nodeFilesystemShared/src/files/PathsNodeJs.kt index efced6b9f..b85ce8439 100644 --- a/core/nodeFilesystemShared/src/files/PathsNodeJs.kt +++ b/core/nodeFilesystemShared/src/files/PathsNodeJs.kt @@ -63,6 +63,12 @@ public actual class Path internal constructor( actual override fun hashCode(): Int { return path.hashCode() } + + public actual fun normalized(): Path = Path(path = if (isWindows) { + normalizedInternal(true, WindowsPathSeparator, UnixPathSeparator) + } else { + normalizedInternal(false, UnixPathSeparator) + }) } public actual val SystemPathSeparator: Char by lazy { diff --git a/core/wasmWasi/src/files/FileSystemWasm.kt b/core/wasmWasi/src/files/FileSystemWasm.kt index 450322dc8..287176140 100644 --- a/core/wasmWasi/src/files/FileSystemWasm.kt +++ b/core/wasmWasi/src/files/FileSystemWasm.kt @@ -272,22 +272,6 @@ internal object WasiFileSystem : SystemFileSystemImpl() { } } -private fun Path.normalized(): Path { - require(isAbsolute) - - val parts = path.split(UnixPathSeparator) - val constructedPath = mutableListOf() - // parts[0] is always empty - for (idx in 1 until parts.size) { - when (val part = parts[idx]) { - "." -> continue - ".." -> constructedPath.removeLastOrNull() - else -> constructedPath.add(part) - } - } - return Path(UnixPathSeparator.toString(), *constructedPath.toTypedArray()) -} - public actual open class FileNotFoundException actual constructor( message: String?, ) : IOException(message) diff --git a/core/wasmWasi/src/files/PathsWasm.kt b/core/wasmWasi/src/files/PathsWasm.kt index 08be220e2..444c6dfd5 100644 --- a/core/wasmWasi/src/files/PathsWasm.kt +++ b/core/wasmWasi/src/files/PathsWasm.kt @@ -48,6 +48,8 @@ public actual class Path internal constructor(rawPath: String, @Suppress("UNUSED } public actual val isAbsolute: Boolean = path.startsWith(SystemPathSeparator) + + public actual fun normalized(): Path = Path(path = normalizedInternal(false, SystemPathSeparator)) } // The path separator is always '/'.