Skip to content

Commit

Permalink
Support path normalization
Browse files Browse the repository at this point in the history
Closes #223
  • Loading branch information
fzhinkin committed Mar 18, 2024
1 parent bae3fe3 commit c59dfb1
Show file tree
Hide file tree
Showing 10 changed files with 102 additions and 16 deletions.
1 change: 1 addition & 0 deletions core/api/kotlinx-io-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
54 changes: 54 additions & 0 deletions core/common/src/files/Paths.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<String>()
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)
}
}
}
9 changes: 9 additions & 0 deletions core/common/test/files/SmokeFileTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,15 @@ class SmokeFileTest {
source.close() // there should be no error
}

@Test
fun pathNormalize() {
assertEquals(Path(""), Path("").normalized())
assertEquals(Path("/a"), Path("/////////////a/").normalized())
assertEquals(Path("/e"), Path("/a/b/../c/../d/../../../e").normalized())
assertEquals(Path("../../e"), Path("a/b/../c/../d/../../../../e").normalized())
assertEquals(Path("a"), Path("a/././././").normalized())
}

private fun constructAbsolutePath(vararg parts: String): String {
return SystemPathSeparator.toString() + parts.joinToString(SystemPathSeparator.toString())
}
Expand Down
8 changes: 8 additions & 0 deletions core/common/test/files/SmokeFileTestWindows.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
8 changes: 8 additions & 0 deletions core/common/test/files/UtilsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
8 changes: 8 additions & 0 deletions core/jvm/src/files/PathsJvm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions core/native/src/files/PathsNative.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions core/nodeFilesystemShared/src/files/PathsNodeJs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 0 additions & 16 deletions core/wasmWasi/src/files/FileSystemWasm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -272,22 +272,6 @@ internal object WasiFileSystem : SystemFileSystemImpl() {
}
}

private fun Path.normalized(): Path {
require(isAbsolute)

val parts = path.split(UnixPathSeparator)
val constructedPath = mutableListOf<String>()
// 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)
Expand Down
2 changes: 2 additions & 0 deletions core/wasmWasi/src/files/PathsWasm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 '/'.
Expand Down

0 comments on commit c59dfb1

Please sign in to comment.