diff --git a/libraries/stdlib/jdk7/src/kotlin/io/path/PathRecursiveFunctions.kt b/libraries/stdlib/jdk7/src/kotlin/io/path/PathRecursiveFunctions.kt index 5623c8e16cb..b00bbb6ff7c 100644 --- a/libraries/stdlib/jdk7/src/kotlin/io/path/PathRecursiveFunctions.kt +++ b/libraries/stdlib/jdk7/src/kotlin/io/path/PathRecursiveFunctions.kt @@ -188,18 +188,35 @@ public fun Path.copyToRecursively( } } + val normalizedTarget = target.normalize() + fun destination(source: Path): Path { val relativePath = source.relativeTo(this@copyToRecursively) - return target.resolve(relativePath.pathString) + val destination = target.resolve(relativePath.pathString) + if (!destination.normalize().startsWith(normalizedTarget)) { + throw IllegalFileNameException( + source, + destination, + "Copying files to outside the specified target directory is prohibited. The directory being recursively copied might contain an entry with an illegal name." + ) + } + return destination } fun error(source: Path, exception: Exception): FileVisitResult { return onError(source, destination(source), exception).toFileVisitResult() } + val stack = arrayListOf() + @Suppress("UNUSED_PARAMETER") fun copy(source: Path, attributes: BasicFileAttributes): FileVisitResult { return try { + if (stack.isNotEmpty()) { + // Check entries other than the starting path of traversal + source.checkFileName() + source.checkNotSameAs(stack.last()) + } DefaultCopyActionContext.copyAction(source, destination(source)).toFileVisitResult() } catch (exception: Exception) { error(source, exception) @@ -207,10 +224,15 @@ public fun Path.copyToRecursively( } visitFileTree(followLinks = followLinks) { - onPreVisitDirectory(::copy) + onPreVisitDirectory { directory, attributes -> + copy(directory, attributes).also { + if (it == FileVisitResult.CONTINUE) stack.add(directory) + } + } onVisitFile(::copy) onVisitFileFailed(::error) onPostVisitDirectory { directory, exception -> + stack.removeLast() if (exception == null) { FileVisitResult.CONTINUE } else { @@ -330,13 +352,13 @@ private fun Path.deleteRecursivelyImpl(): List { if (stream is SecureDirectoryStream) { useInsecure = false collector.path = parent - stream.handleEntry(this.fileName, collector) + stream.handleEntry(this.fileName, null, collector) } } } if (useInsecure) { - insecureHandleEntry(this, collector) + insecureHandleEntry(this, null, collector) } return collector.collectedExceptions @@ -356,10 +378,16 @@ private inline fun tryIgnoreNoSuchFileException(function: () -> R): R? { // secure walk -private fun SecureDirectoryStream.handleEntry(name: Path, collector: ExceptionsCollector) { +private fun SecureDirectoryStream.handleEntry(name: Path, parent: Path?, collector: ExceptionsCollector) { collector.enterEntry(name) collectIfThrows(collector) { + if (parent != null) { + // Check entries other than the starting path of traversal + val entry = collector.path!! + entry.checkFileName() + entry.checkNotSameAs(parent) + } if (this.isDirectory(name, LinkOption.NOFOLLOW_LINKS)) { val preEnterTotalExceptions = collector.totalExceptions @@ -384,7 +412,7 @@ private fun SecureDirectoryStream.enterDirectory(name: Path, collector: Ex this.newDirectoryStream(name, LinkOption.NOFOLLOW_LINKS) }?.use { directoryStream -> for (entry in directoryStream) { - directoryStream.handleEntry(entry.fileName, collector) + directoryStream.handleEntry(entry.fileName, collector.path, collector) } } } @@ -398,8 +426,13 @@ private fun SecureDirectoryStream.isDirectory(entryName: Path, vararg opti // insecure walk -private fun insecureHandleEntry(entry: Path, collector: ExceptionsCollector) { +private fun insecureHandleEntry(entry: Path, parent: Path?, collector: ExceptionsCollector) { collectIfThrows(collector) { + if (parent != null) { + // Check entries other than the starting path of traversal + entry.checkFileName() + entry.checkNotSameAs(parent) + } if (entry.isDirectory(LinkOption.NOFOLLOW_LINKS)) { val preEnterTotalExceptions = collector.totalExceptions @@ -422,8 +455,67 @@ private fun insecureEnterDirectory(path: Path, collector: ExceptionsCollector) { Files.newDirectoryStream(path) }?.use { directoryStream -> for (entry in directoryStream) { - insecureHandleEntry(entry, collector) + insecureHandleEntry(entry, path, collector) } } } } + +// illegal file name + +/** + * Checks whether the name of this file is legal for traversal to prevent cycles. + * + * Some names are considered illegal as they may cause traversal cycles. + * This function is intended for use with entries whose parent directories have already been traversed. + * The file being checked is not the starting point of traversal. + * + * For instance, "/a/b/.." is a valid starting path for traversal. However, if traversal begins from "/a" + * and reaches "a/b/..", it will result in a cycle. + * + * @throws IllegalFileNameException if the file name is "..", "../", , "..\", ".", "./", or ".\" since these may lead to traversal cycles. + * + * See KT-63103 for more details on the issue. + */ +internal fun Path.checkFileName() { + val fileName = this.name + if (fileName == ".." || fileName == "../" || fileName == "..\\" || + fileName == "." || fileName == "./" || fileName == ".\\") throw IllegalFileNameException(this) +} + +/** + * Checks that this entry is not the same as the specified [parent] path to prevent traversal cycles. + * + * When reading entries of a directory, there are cases where the directory itself is returned, + * such as when a zip entry name is '/'. Including the directory itself in the list of its entries can lead to traversal cycles. + * + * Unfortunately, [Files.walkFileTree], utilized in [copyToRecursively], may not detect such cycles when links are not followed. + * Similarly, [deleteRecursively] lacks cycle detection capabilities as it never follows links. + * + * This function is intended for use with entries whose parent directories have already been traversed. + * The file being checked is not the starting point of traversal. + * + * For instance, "/a/b/.." is a valid starting path for traversal. However, if traversal begins from "/a" + * and reaches "a/b/..", it will result in a cycle. + * + * @throws FileSystemLoopException if this entry is the same as the [parent] path, indicating a potential traversal cycle. + * + * See KT-63103 for more details on the issue. + */ +private fun Path.checkNotSameAs(parent: Path) { + // Symlinks are skipped: + // If this path is a symlink pointing to [parent] and links are not followed, the path is perfectly fine for traversal. + // However, [Path.isSameFileAs] always follows links. + // [parent] can't be a symlink: + // Otherwise, it would mean links are followed and [Files.walkFileTree] would have already detected the cycle. + if (!isSymbolicLink() && isSameFileAs(parent)) + throw FileSystemLoopException(this.toString()) +} + +internal class IllegalFileNameException( + file: Path, + other: Path?, + message: String? +) : FileSystemException(file.toString(), other?.toString(), message) { + constructor(file: Path) : this(file, null, null) +} diff --git a/libraries/stdlib/jdk7/src/kotlin/io/path/PathTreeWalk.kt b/libraries/stdlib/jdk7/src/kotlin/io/path/PathTreeWalk.kt index 79580e58b30..d0419f89618 100644 --- a/libraries/stdlib/jdk7/src/kotlin/io/path/PathTreeWalk.kt +++ b/libraries/stdlib/jdk7/src/kotlin/io/path/PathTreeWalk.kt @@ -43,6 +43,10 @@ internal class PathTreeWalk( entriesAction: (List) -> Unit ) { val path = node.path + if (node.parent != null) { + // Check entries other than the starting path of traversal + path.checkFileName() + } if (path.isDirectory(*linkOptions)) { if (node.createsCycle()) throw FileSystemLoopException(path.toString()) diff --git a/libraries/stdlib/jdk7/test/PathRecursiveFunctionsTest.kt b/libraries/stdlib/jdk7/test/PathRecursiveFunctionsTest.kt index b9f648aad49..35aa618bcf4 100644 --- a/libraries/stdlib/jdk7/test/PathRecursiveFunctionsTest.kt +++ b/libraries/stdlib/jdk7/test/PathRecursiveFunctionsTest.kt @@ -5,10 +5,7 @@ package kotlin.jdk7.test -import java.net.URI import java.nio.file.* -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream import kotlin.io.path.* import kotlin.jdk7.test.PathTreeWalkTest.Companion.createTestFiles import kotlin.jdk7.test.PathTreeWalkTest.Companion.referenceFilenames @@ -973,50 +970,4 @@ class PathRecursiveFunctionsTest : AbstractPathTest() { src.copyToRecursively(dst, followLinks = false, overwrite = true) } - - private fun createZipFile(parent: Path, name: String): Path { - val zipRoot = parent.resolve(name) - ZipOutputStream(zipRoot.outputStream()).use { out -> - out.putNextEntry(ZipEntry("directory/file.txt")) - out.write("hello".toByteArray()) - out.closeEntry() - } - return zipRoot - } - - @Test - fun zipToDefaultPath() { - val root = createTempDirectory().cleanupRecursively() - val zipRoot = createZipFile(root, "src.zip") - val dst = root.resolve("dst") - - val classLoader: ClassLoader? = null - FileSystems.newFileSystem(zipRoot, classLoader).use { zipFs -> - val src = zipFs.getPath("/directory") - - src.copyToRecursively(dst, followLinks = false) - - val expected = listOf("", "file.txt") - testVisitedFiles(expected, dst.walkIncludeDirectories(), dst) - assertEquals("hello", dst.resolve("file.txt").readText()) - } - } - - @Test - fun defaultPathToZip() { - val root = createTestFiles().cleanupRecursively() - val zipRoot = createZipFile(root, "dst.zip") - val src = root.resolve("1").also { it.resolve("3/4.txt").writeText("hello") } - - val classLoader: ClassLoader? = null - FileSystems.newFileSystem(zipRoot, classLoader).use { zipFs -> - val dst = zipFs.getPath("/directory") - - src.copyToRecursively(dst, followLinks = false) - - val expected = listOf("", "2", "3", "3/4.txt", "3/5.txt", "file.txt") - testVisitedFiles(expected, dst.walkIncludeDirectories(), dst) - assertEquals("hello", zipFs.getPath("/directory/3/4.txt").readText()) - } - } } diff --git a/libraries/stdlib/jdk7/test/PathRecursiveFunctionsZipTest.kt b/libraries/stdlib/jdk7/test/PathRecursiveFunctionsZipTest.kt new file mode 100644 index 00000000000..fd315ea42ce --- /dev/null +++ b/libraries/stdlib/jdk7/test/PathRecursiveFunctionsZipTest.kt @@ -0,0 +1,605 @@ +/* + * Copyright 2010-2024 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +package kotlin.jdk7.test + +import test.testOnJvm8 +import test.testOnJvm9AndAbove +import java.lang.NullPointerException +import java.nio.file.* +import java.util.zip.ZipEntry +import java.util.zip.ZipException +import java.util.zip.ZipOutputStream +import kotlin.io.path.* +import kotlin.jdk7.test.PathTreeWalkTest.Companion.createTestFiles +import kotlin.jdk7.test.PathTreeWalkTest.Companion.referenceFilenames +import kotlin.jdk7.test.PathTreeWalkTest.Companion.testVisitedFiles +import kotlin.test.* + +class PathRecursiveFunctionsZipTest : AbstractPathTest() { + + private fun Path.walkIncludeDirectories(): Sequence = + this.walk(PathWalkOption.INCLUDE_DIRECTORIES) + + private fun createZipFile(parent: Path, name: String, entries: List): Path { + val zipRoot = parent.resolve(name) + ZipOutputStream(zipRoot.outputStream()).use { out -> + for (fileName in entries) { + out.putNextEntry(ZipEntry(fileName)) + if (!fileName.endsWith("/")) { + out.write(fileName.toByteArray()) + } + out.closeEntry() + } + } + return zipRoot + } + + private fun withZip(name: String, entries: List, block: (parent: Path, zipRoot: Path) -> Unit) { + val parent = createTempDirectory().cleanupRecursively() + val archive = createZipFile(parent, name, entries) + val zipFs: FileSystem + try { + val classLoader: ClassLoader? = null + zipFs = FileSystems.newFileSystem(archive, classLoader) + } catch (e: ZipException) { + // Later JDK versions do not allow opening zip files that have entry named "." or "..". + // See https://bugs.openjdk.org/browse/JDK-8251329 and https://bugs.openjdk.org/browse/JDK-8283486 + if (e.message?.contains("a '.' or '..' element") == true) { + println("Opening a ZIP file failed with $e") + } else { + // Potentially a different cause for the failed opening. + throw e + } + return + } + zipFs.use { + val zipRoot = it.getPath("/") + block(parent, zipRoot) + } + } + + + @Test + fun zipToDefaultPath() { + withZip("src.zip", listOf("directory/", "directory/file.txt")) { root, zipRoot -> + val dst = root.resolve("dst") + val src = zipRoot.resolve("directory") + + src.copyToRecursively(dst, followLinks = false) + + val expected = listOf("", "file.txt") + testVisitedFiles(expected, dst.walkIncludeDirectories(), dst) + assertEquals("directory/file.txt", dst.resolve("file.txt").readText()) + } + } + + @Test + fun defaultPathToZip() { + val srcRoot = createTestFiles().cleanupRecursively() + withZip("dst.zip", listOf("directory/", "directory/file.txt")) { _, zipRoot -> + val src = srcRoot.resolve("1").also { it.resolve("3/4.txt").writeText("hello") } + val dst = zipRoot.resolve("directory") + + src.copyToRecursively(dst, followLinks = false) + + val expected = listOf("", "2", "3", "3/4.txt", "3/5.txt", "file.txt") + testVisitedFiles(expected, dst.walkIncludeDirectories(), dst) + assertEquals("hello", zipRoot.resolve("directory/3/4.txt").readText()) + } + } + + private fun testWalkSucceeds(path: Path, vararg expectedContent: Set) { + val content = path.walkIncludeDirectories().toSet() + assertContains(expectedContent.toList(), content) + } + + private fun testWalkFailsWithIllegalFileName(path: Path) { + assertFailsWith { + path.walkIncludeDirectories().toList() + } + } + + private inline fun testWalkMaybeFailsWith(path: Path, vararg expectedContent: Set) { + try { + testWalkSucceeds(path, *expectedContent) + } catch (exception: Exception) { + assertIs(exception) + } + } + + private fun testCopySucceeds(source: Path, target: Path, vararg expectedTargetContent: Set) { + source.copyToRecursively(target, followLinks = false) + val content = target.walkIncludeDirectories().toSet() + assertContains(expectedTargetContent.toList(), content) + } + + private fun testCopyFailsWithIllegalFileName(source: Path, target: Path) { + assertFailsWith { + source.copyToRecursively(target, followLinks = false) + } + } + + private inline fun testCopyMaybeFailsWith(source: Path, target: Path, expectedTargetContent: Set) { + try { + testCopySucceeds(source, target, expectedTargetContent) + } catch (exception: Exception) { + assertIs(exception) + } + } + + private fun testDeleteSucceeds(path: Path) { + path.deleteRecursively() + assertFalse(path.exists()) + } + + private inline fun testDeleteFailsWith(path: Path) { + assertFailsWith { + path.deleteRecursively() + }.also { exception -> + val suppressed = exception.suppressed.single() + assertIs(suppressed) + } + } + + private inline fun testDeleteMaybeFailsWith(path: Path): Boolean { + return try { + testDeleteSucceeds(path) + true + } catch (exception: FileSystemException) { + val suppressed = exception.suppressed.single() + assertIs(suppressed) + false + } + } + + private fun Path.resolve(vararg entryNames: String): Set { + return entryNames.map { resolve(it) }.toSet() + } + + @Test + fun zipDotFileName() { + withZip("Archive1.zip", listOf("normal", ".")) { root, zipRoot -> + val dotFile = zipRoot.resolve(".") + val dotDir = zipRoot.resolve("./") + testWalkFailsWithIllegalFileName(zipRoot) + testWalkFailsWithIllegalFileName(dotFile) + // Succeeds on jvm8, fails on jvm9+ + testWalkMaybeFailsWith(dotDir, setOf(dotDir)) + + val target = root.resolve("UnzipArchive1") + testCopyFailsWithIllegalFileName(zipRoot, target) + val dotFileTarget = root.resolve("UnzipArchive1-dotFile") + testCopyFailsWithIllegalFileName(dotFile, dotFileTarget) + val dotDirTarget = root.resolve("UnzipArchive1-dotDir") + // Succeeds on jvm8, fails on jvm9+ + testCopyMaybeFailsWith(dotDir, dotDirTarget, setOf(dotDirTarget)) + + testDeleteFailsWith(zipRoot) + testDeleteFailsWith(dotFile) + // Succeeds on jvm8, fails on jvm9+ + testDeleteMaybeFailsWith(dotDir) + assertTrue(dotFile.exists()) + assertTrue(zipRoot.exists()) + } + + withZip("Archive2.zip", listOf("normal", "./")) { root, zipRoot -> + val dotFile = zipRoot.resolve(".") + val dotDir = zipRoot.resolve("./") + testWalkFailsWithIllegalFileName(zipRoot) + testWalkFailsWithIllegalFileName(dotFile) + // Succeeds on jvm8, fails on jvm9+ + testWalkMaybeFailsWith(dotDir, setOf(dotDir)) + + val target = root.resolve("UnzipArchive2") + testCopyFailsWithIllegalFileName(zipRoot, target) + val dotFileTarget = root.resolve("UnzipArchive2-dotFile") + testCopyFailsWithIllegalFileName(dotFile, dotFileTarget) + val dotDirTarget = root.resolve("UnzipArchive2-dotDir") + // Succeeds on jvm8, fails on jvm9+ + testCopyMaybeFailsWith(dotDir, dotDirTarget, setOf(dotDirTarget)) + + testDeleteFailsWith(zipRoot) + testDeleteFailsWith(dotFile) + // Succeeds on jvm8, fails on jvm9+ + testDeleteMaybeFailsWith(dotDir) + assertTrue(dotFile.exists()) + assertTrue(zipRoot.exists()) + } + + withZip("Archive3.zip", listOf("a/", "a/.")) { root, zipRoot -> + val a = zipRoot.resolve("a") + testWalkFailsWithIllegalFileName(zipRoot) + testWalkFailsWithIllegalFileName(a) + + val target = root.resolve("UnzipArchive3") + testCopyFailsWithIllegalFileName(zipRoot, target) + val aTarget = root.resolve("UnzipArchive3-a") + testCopyFailsWithIllegalFileName(a, aTarget) + + testDeleteFailsWith(zipRoot) + testDeleteFailsWith(a) + } + + withZip("Archive4.zip", listOf("a/", "a/./")) { root, zipRoot -> + val a = zipRoot.resolve("a") + testWalkFailsWithIllegalFileName(zipRoot) + testWalkFailsWithIllegalFileName(a) + + val target = root.resolve("UnzipArchive4") + testCopyFailsWithIllegalFileName(zipRoot, target) + val aTarget = root.resolve("UnzipArchive4-a") + testCopyFailsWithIllegalFileName(a, aTarget) + + testDeleteFailsWith(zipRoot) + testDeleteFailsWith(a) + } + } + + @Test + fun zipSlashFileName() { + withZip("Archive1.zip", listOf("normal", "/")) { root, zipRoot -> + // Fails in jvm8-10, succeeds in jvm11 + testWalkMaybeFailsWith(zipRoot, zipRoot.resolve("", "normal")) + + val target = root.resolve("UnzipArchive1") + // Fails in jvm8-10, succeeds in jvm11 + testCopyMaybeFailsWith(zipRoot, target, target.resolve("", "normal")) + + // Throws FileSystemLoopException in jvm8-10 + // Path.deleteIfExists on the root directory of the archive throws NullPointerException in jvm9+ + assertFails { zipRoot.deleteRecursively() } + } + + withZip("Archive2.zip", listOf("normal", "//")) { root, zipRoot -> + // Fails in jvm8, succeeds in jvm9+ + try { + zipRoot.walkIncludeDirectories().toList() + // ["/", "//", "normal"] in jvm9-10 + // ["/", "/normal"] in jvm11 + } catch (exception: Exception) { + assertIs(exception) + } + + val target = root.resolve("UnzipArchive2") + // Fails in jvm8, succeeds in jvm9+ + testCopyMaybeFailsWith(zipRoot, target, target.resolve("", "normal")) + + // Throws FileSystemLoopException in jvm8 + // Path.deleteIfExists on the root directory of the archive throws NullPointerException in jvm9+ + assertFails { zipRoot.deleteRecursively() } + } + + withZip("Archive3.zip", listOf("a/", "a//")) { root, zipRoot -> + val aFile = zipRoot.resolve("a") + val aDir = zipRoot.resolve("a/") + // Fails in jvm8, succeeds in jvm9+ + try { + zipRoot.walkIncludeDirectories().toList() + // ["/", "/a", "/a/"] in jvm9-10 + // ["/", "/a"] in jvm11 + } catch (exception: Exception) { + assertIs(exception) + } + testWalkMaybeFailsWith(aFile, setOf(aFile)) + testWalkMaybeFailsWith(aDir, setOf(aFile)) + + // Fails in jvm8, succeeds in jvm9+ + val zipRootTarget = root.resolve("UnzipArchive3") + testCopyMaybeFailsWith(zipRoot, zipRootTarget, zipRootTarget.resolve("", "a")) + val aFileTarget = root.resolve("UnzipArchive3-aFile") + testCopyMaybeFailsWith(aFile, aFileTarget, setOf(aFileTarget)) + val aDirTarget = root.resolve("UnzipArchive3-aDir") + // Fails with jdk8 "IllegalFileNameException: Copying files to outside the specified target directory is prohibited." + testCopyMaybeFailsWith(aDir, aDirTarget, setOf(aDirTarget)) + + // Throws FileSystemLoopException in jvm8 + // Path.deleteIfExists on the root directory of the archive throws NullPointerException in jvm9+ + assertFails { zipRoot.deleteRecursively() } + // Fails in jvm8, succeeds in jvm9+ + testDeleteMaybeFailsWith(aFile) + testDeleteMaybeFailsWith(aDir) + } + } + + @Test + fun copyOutsideTargetIsProhibited() { + withZip("Archive.zip", listOf("a", "a//")) { root, zipRoot -> + val aDir = zipRoot.resolve("a/") + testWalkSucceeds(aDir, zipRoot.resolve("a/", "a")) + + val aDirTarget = root.resolve("UnzipArchive-aDir") + // Fails with jdk8 "IllegalFileNameException: Copying files to outside the specified target directory is prohibited." + testCopyMaybeFailsWith(aDir, aDirTarget, setOf(aDirTarget)) + // No file is copied outside the target + testWalkSucceeds(root, root.resolve("", "Archive.zip", "UnzipArchive-aDir")) + } + } + + @Test + fun deleteZipRootDirectory() { + withZip("Archive.zip", emptyList()) { _, zipRoot -> + // Deleting the root directory of a zip archive throws NullPointerException. + assertFailsWith { + zipRoot.deleteIfExists() + } + } + } + + // KT-63103 + @Test + fun zipDoubleDotsFileName() { + withZip("Archive1.zip", listOf("normal", "../sneaky")) { root, zipRoot -> + root.resolve("sneaky").createFile().also { it.writeText("outer sneaky") } + testWalkFailsWithIllegalFileName(zipRoot) + + val target = root.resolve("UnzipArchive1") + testCopyFailsWithIllegalFileName(zipRoot, target) + + testDeleteFailsWith(zipRoot) + } + withZip("Archive2.zip", listOf("normal", "../normal")) { root, zipRoot -> + testWalkFailsWithIllegalFileName(zipRoot) + + val target = root.resolve("UnzipArchive2") + testCopyFailsWithIllegalFileName(zipRoot, target) + + testDeleteFailsWith(zipRoot) + } + + withZip("Archive3.zip", listOf("normal", "../")) { root, zipRoot -> + testWalkFailsWithIllegalFileName(zipRoot) + + val target = root.resolve("UnzipArchive3") + testCopyFailsWithIllegalFileName(zipRoot, target) + + testDeleteFailsWith(zipRoot) + } + + withZip("Archive4.zip", listOf("normal", "..")) { root, zipRoot -> + testWalkFailsWithIllegalFileName(zipRoot) + + val target = root.resolve("UnzipArchive4") + testCopyFailsWithIllegalFileName(zipRoot, target) + + testDeleteFailsWith(zipRoot) + } + + withZip("Archive5.zip", listOf("normal", "../..")) { root, zipRoot -> + testWalkFailsWithIllegalFileName(zipRoot) + + val target = root.resolve("UnzipArchive5") + testCopyFailsWithIllegalFileName(zipRoot, target) + + testDeleteFailsWith(zipRoot) + } + + withZip("Archive6.zip", listOf("normal", "../")) { root, zipRoot -> + val targetParent = root.resolve("UnzipArchive6Parent").createDirectory() + val targetSibling = targetParent.resolve("UnzipArchive6Sibling").createFile() + val target = targetParent.resolve("UnzipArchive6").createDirectory() + assertFailsWith { + zipRoot.copyToRecursively(target, followLinks = false) { src, dst -> + if (dst != target) { + dst.deleteRecursively() + src.copyTo(dst) + } + CopyActionResult.CONTINUE + } + } + + assertTrue(target.exists()) + assertTrue(targetSibling.exists()) + } + + withZip("Archive7.zip", listOf("normal", "..a..b..")) { root, zipRoot -> + testWalkSucceeds(zipRoot, zipRoot.resolve("", "normal", "..a..b..")) + + val target = root.resolve("UnzipArchive7") + val unix = target.resolve("", "normal", "..a..b..") // In Linux and macOS + val windows = target.resolve("", "normal", "..a..b") // In Windows + testCopySucceeds(zipRoot, target, unix, windows) + + // Path.deleteIfExists on the root directory of the archive throws NullPointerException + testDeleteFailsWith(zipRoot) + assertEquals(emptyList(), zipRoot.listDirectoryEntries()) + } + + withZip("Archive8.zip", listOf("b", "a/", "a/../b")) { root, zipRoot -> + val a = zipRoot.resolve("a") + + testWalkFailsWithIllegalFileName(a) + + val target = root.resolve("UnzipArchive8") + testCopyFailsWithIllegalFileName(a, target) + + testDeleteFailsWith(a) + } + + withZip("Archive9.zip", listOf("b/", "b/d", "a/", "a/../b/c")) { root, zipRoot -> + val b = zipRoot.resolve("a/../b") + // Traverses the "b" directory outside "a" + val jvm8 = zipRoot.resolve("a/../b", "b/d") + val jvm11 = zipRoot.resolve("a/../b", "a/../b/d") + testWalkSucceeds(b, jvm8, jvm11) + + val target = root.resolve("UnzipArchive9") + // Copied content of the "b" directory that is outside "a" + testCopySucceeds(b, target, target.resolve("", "d")) + + testDeleteSucceeds(b) + // The deleted "/a/../b" path actually deleted the "b" outside "a" + assertNull(zipRoot.listDirectoryEntries().find { it.name == "b" }) + } + } + + @Test + fun zipWindowsPathSeparators() { + // When creating a zip archive, entries are added with the exact given names. + testOnJvm8 { + // JDK8 converts backslashes to slashes when reading entries, but later can't find those entries + withZip("Archive1.zip", listOf("b\\", "b\\d", "a\\")) { root, zipRoot -> + assertFailsWith { + zipRoot.walkIncludeDirectories().toList() + } + + // There is no directory with name "a", thus empty walk sequence + testWalkSucceeds(zipRoot.resolve("a"), emptySet()) + + assertFailsWith { + val target = root.resolve("UnzipArchive1") + zipRoot.copyToRecursively(target, followLinks = false) + } + + assertFailsWith { + zipRoot.deleteRecursively() + }.also { exception -> + exception.suppressed.forEach { + assertIs(it) + } + } + } + } + + testOnJvm9AndAbove { + // JDK9+ treats backslashes as part of entry name + withZip("Archive1.zip", listOf("b\\", "b\\d", "a\\", "a\\..\\b\\c")) { root, zipRoot -> + val expectedWalk = listOf("", "b\\", "b\\d", "a\\", "a\\..\\b\\c").map { "/$it" }.toSet() + val walk = zipRoot.walkIncludeDirectories().map { it.toString() }.toSet() + assertEquals(expectedWalk, walk) + + // There is no directory with name "a", thus empty walk sequence + testWalkSucceeds(zipRoot.resolve("a"), emptySet()) + + val target = root.resolve("UnzipArchive1") + // Fails in Windows + testCopyMaybeFailsWith(zipRoot, target, target.resolve("", "b\\", "b\\d", "a\\", "a\\..\\b\\c")) + + // Deleting a zip root throws NPE + testDeleteFailsWith(zipRoot) + // All entries inside are deleted + testWalkSucceeds(zipRoot, setOf(zipRoot)) + } + } + } + + @Test + fun copyIllegalFileNameExceptionPassedToOnError() { + withZip("Archive1.zip", listOf("normal", "..")) { root, zipRoot -> + val target = root.resolve("UnzipArchive1") + var failed = false + zipRoot.copyToRecursively(target, followLinks = false, onError = { _, _, exception -> + failed = true + assertIs(exception) + OnErrorResult.SKIP_SUBTREE + }) + assertTrue(failed) + } + withZip("Archive2.zip", listOf("normal", "/")) { root, zipRoot -> + val target = root.resolve("UnzipArchive2") + // FileSystemLoopException in jvm8-10, no exception in jvm11 + zipRoot.copyToRecursively(target, followLinks = false, onError = { _, _, exception -> + assertIs(exception) + OnErrorResult.SKIP_SUBTREE + }) + } + withZip("Archive3.zip", listOf("normal", ".")) { root, zipRoot -> + val target = root.resolve("UnzipArchive3") + var failed = false + zipRoot.copyToRecursively(target, followLinks = false, onError = { _, _, exception -> + failed = true + assertIs(exception) + OnErrorResult.SKIP_SUBTREE + }) + assertTrue(failed) + } + } + + // To demonstrate how recursive functions behave when the traversal starting path ends with "." or ".." + // in the default file system, not inside a zip. + @Test + fun legalDirectorySymbols() { + createTestFiles().cleanupRecursively().let { root -> + val path = root.resolve("1/3/.") + + testWalkSucceeds(path, path.resolve("", "4.txt", "5.txt")) + + val target = createTempDirectory().cleanupRecursively().resolve("target") + testCopySucceeds(path, target, target.resolve("", "4.txt", "5.txt")) + + // Fails in Linux and macOS, succeeds in Windows + // deleteIfExists/deleteDirectory() throws FileSystemException: /1/3/.: Invalid argument + val deleteSucceeded = testDeleteMaybeFailsWith(path) + if (deleteSucceeded) { + // Only the "1/3" directory and its content is deleted + val expectedRootContent = setOf(root) + referenceFilenames.filter { !it.contains("3") }.map { root.resolve(it) } + testWalkSucceeds(root, expectedRootContent) + } + } + + createTestFiles().cleanupRecursively().let { root -> + val path = root.resolve("1/3/4.txt/.") + + val unix = emptySet() // In Linux and macOS + val windows = setOf(path) // In Windows + testWalkSucceeds(path, unix, windows) + + val target = createTempDirectory().cleanupRecursively().resolve("target") + // Copy fails in Linux and macOS, succeeds in Windows + // Path.copyToRecursively throws NoSuchFileException: /1/3/4.txt/.: The source file doesn't exist. + testCopyMaybeFailsWith(path, target, setOf(target)) + + // Delete fails in Linux and macOS, succeeds in Windows + // Path.deleteIfExists() throws FileSystemException: /1/3/4.txt/.: Not a directory + val deleteSucceeded = testDeleteMaybeFailsWith(path) + if (deleteSucceeded) { + // Only the "1/3/4.txt" file is deleted + val expectedRootContent = setOf(root) + referenceFilenames.filter { !it.contains("4.txt") }.map { root.resolve(it) } + testWalkSucceeds(root, expectedRootContent) + } + } + + createTestFiles().cleanupRecursively().let { root -> + val path = root.resolve("1/3/..") + + testWalkSucceeds(path, path.resolve("", "2", "3", "3/4.txt", "3/5.txt")) + + val target = createTempDirectory().cleanupRecursively().resolve("target") + testCopySucceeds(path, target, target.resolve("", "2", "3", "3/4.txt", "3/5.txt")) + + // Fails in macOS and Linux, succeeds in Windows + // In macOS Path.isSameFileAs() throws NoSuchFileException: /1/3/../2 + // In Linux deleteDirectory() throws DirectoryNotEmptyException wrapped into FileSystemException: /1/3/.. + val deleteSucceeded = testDeleteMaybeFailsWith(path) + if (deleteSucceeded) { + // Only the "1" directory and its content is deleted + val expectedRootContent = setOf(root) + referenceFilenames.filter { !it.contains("1") }.map { root.resolve(it) } + testWalkSucceeds(root, expectedRootContent) + } + } + + createTestFiles().cleanupRecursively().let { root -> + val path = root.resolve("1/3/4.txt/..") + + val unix = emptySet() // In Linux and macOS + val windows = path.resolve("", "4.txt", "5.txt") // In Windows + testWalkSucceeds(path, unix, windows) + + val target = createTempDirectory().cleanupRecursively().resolve("target") + // Copy fails in Linux and macOS, succeeds in Windows + // Path.copyToRecursively throws NoSuchFileException: /1/3/4.txt/..: The source file doesn't exist. + testCopyMaybeFailsWith(path, target, setOf(target, target.resolve("4.txt"), target.resolve("5.txt"))) + + // Delete fails in Linux and macOS, succeeds in Windows + // Path.deleteIfExists() throws FileSystemException: /1/3/4.txt/..: Not a directory + val deleteSucceeded = testDeleteMaybeFailsWith(path) + if (deleteSucceeded) { + // Only the "1/3" directory and its content is deleted + val expectedRootContent = setOf(root) + referenceFilenames.filter { !it.contains("3") }.map { root.resolve(it) } + testWalkSucceeds(root, expectedRootContent) + } + } + } +} \ No newline at end of file diff --git a/libraries/stdlib/jvm/test/testUtilsJVM.kt b/libraries/stdlib/jvm/test/testUtilsJVM.kt index 236ca471b57..39035c08e1e 100644 --- a/libraries/stdlib/jvm/test/testUtilsJVM.kt +++ b/libraries/stdlib/jvm/test/testUtilsJVM.kt @@ -8,6 +8,21 @@ package test import java.util.* import kotlin.test.assertEquals +private val isJava8 = System.getProperty("java.version").startsWith("1.8.") + +internal fun testOnJvm8(f: () -> Unit) { + if (isJava8) { + f() + } +} + +internal fun testOnJvm9AndAbove(f: () -> Unit) { + if (!isJava8) { + f() + } +} + + public actual fun assertTypeEquals(expected: Any?, actual: Any?) { assertEquals(expected?.javaClass, actual?.javaClass) }