Fix security vulnerability in Path recursive functions #KT-63103

* Prohibit files with name "." or ".."
* Detect cycles caused by files with name "/"
* Prohibit copy outside the target directory

The commit also moves zip-related tests to PathRecursiveFunctionsZipTest.
This commit is contained in:
Abduqodiri Qurbonzoda
2023-11-06 16:18:37 +02:00
committed by Space Team
parent 4c06673483
commit f8c587ddcf
5 changed files with 724 additions and 57 deletions
@@ -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<Path>()
@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<Exception> {
if (stream is SecureDirectoryStream<Path>) {
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 <R> tryIgnoreNoSuchFileException(function: () -> R): R? {
// secure walk
private fun SecureDirectoryStream<Path>.handleEntry(name: Path, collector: ExceptionsCollector) {
private fun SecureDirectoryStream<Path>.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<Path>.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<Path>.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)
}
@@ -43,6 +43,10 @@ internal class PathTreeWalk(
entriesAction: (List<PathNode>) -> 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())
@@ -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())
}
}
}
@@ -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<Path> =
this.walk(PathWalkOption.INCLUDE_DIRECTORIES)
private fun createZipFile(parent: Path, name: String, entries: List<String>): 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<String>, 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<Path>) {
val content = path.walkIncludeDirectories().toSet()
assertContains(expectedContent.toList(), content)
}
private fun testWalkFailsWithIllegalFileName(path: Path) {
assertFailsWith<IllegalFileNameException> {
path.walkIncludeDirectories().toList()
}
}
private inline fun <reified T> testWalkMaybeFailsWith(path: Path, vararg expectedContent: Set<Path>) {
try {
testWalkSucceeds(path, *expectedContent)
} catch (exception: Exception) {
assertIs<T>(exception)
}
}
private fun testCopySucceeds(source: Path, target: Path, vararg expectedTargetContent: Set<Path>) {
source.copyToRecursively(target, followLinks = false)
val content = target.walkIncludeDirectories().toSet()
assertContains(expectedTargetContent.toList(), content)
}
private fun testCopyFailsWithIllegalFileName(source: Path, target: Path) {
assertFailsWith<IllegalFileNameException> {
source.copyToRecursively(target, followLinks = false)
}
}
private inline fun <reified T> testCopyMaybeFailsWith(source: Path, target: Path, expectedTargetContent: Set<Path>) {
try {
testCopySucceeds(source, target, expectedTargetContent)
} catch (exception: Exception) {
assertIs<T>(exception)
}
}
private fun testDeleteSucceeds(path: Path) {
path.deleteRecursively()
assertFalse(path.exists())
}
private inline fun <reified T> testDeleteFailsWith(path: Path) {
assertFailsWith<FileSystemException> {
path.deleteRecursively()
}.also { exception ->
val suppressed = exception.suppressed.single()
assertIs<T>(suppressed)
}
}
private inline fun <reified T> testDeleteMaybeFailsWith(path: Path): Boolean {
return try {
testDeleteSucceeds(path)
true
} catch (exception: FileSystemException) {
val suppressed = exception.suppressed.single()
assertIs<T>(suppressed)
false
}
}
private fun Path.resolve(vararg entryNames: String): Set<Path> {
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<IllegalFileNameException>(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<IllegalFileNameException>(dotDir, dotDirTarget, setOf(dotDirTarget))
testDeleteFailsWith<IllegalFileNameException>(zipRoot)
testDeleteFailsWith<IllegalFileNameException>(dotFile)
// Succeeds on jvm8, fails on jvm9+
testDeleteMaybeFailsWith<IllegalFileNameException>(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<IllegalFileNameException>(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<IllegalFileNameException>(dotDir, dotDirTarget, setOf(dotDirTarget))
testDeleteFailsWith<IllegalFileNameException>(zipRoot)
testDeleteFailsWith<IllegalFileNameException>(dotFile)
// Succeeds on jvm8, fails on jvm9+
testDeleteMaybeFailsWith<IllegalFileNameException>(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<IllegalFileNameException>(zipRoot)
testDeleteFailsWith<IllegalFileNameException>(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<IllegalFileNameException>(zipRoot)
testDeleteFailsWith<IllegalFileNameException>(a)
}
}
@Test
fun zipSlashFileName() {
withZip("Archive1.zip", listOf("normal", "/")) { root, zipRoot ->
// Fails in jvm8-10, succeeds in jvm11
testWalkMaybeFailsWith<FileSystemLoopException>(zipRoot, zipRoot.resolve("", "normal"))
val target = root.resolve("UnzipArchive1")
// Fails in jvm8-10, succeeds in jvm11
testCopyMaybeFailsWith<FileSystemLoopException>(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<FileSystemLoopException>(exception)
}
val target = root.resolve("UnzipArchive2")
// Fails in jvm8, succeeds in jvm9+
testCopyMaybeFailsWith<FileSystemLoopException>(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<FileSystemLoopException>(exception)
}
testWalkMaybeFailsWith<FileSystemLoopException>(aFile, setOf(aFile))
testWalkMaybeFailsWith<FileSystemLoopException>(aDir, setOf(aFile))
// Fails in jvm8, succeeds in jvm9+
val zipRootTarget = root.resolve("UnzipArchive3")
testCopyMaybeFailsWith<FileSystemLoopException>(zipRoot, zipRootTarget, zipRootTarget.resolve("", "a"))
val aFileTarget = root.resolve("UnzipArchive3-aFile")
testCopyMaybeFailsWith<FileSystemLoopException>(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<IllegalFileNameException>(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<FileSystemLoopException>(aFile)
testDeleteMaybeFailsWith<FileSystemLoopException>(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<IllegalFileNameException>(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<NullPointerException> {
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<IllegalFileNameException>(zipRoot)
}
withZip("Archive2.zip", listOf("normal", "../normal")) { root, zipRoot ->
testWalkFailsWithIllegalFileName(zipRoot)
val target = root.resolve("UnzipArchive2")
testCopyFailsWithIllegalFileName(zipRoot, target)
testDeleteFailsWith<IllegalFileNameException>(zipRoot)
}
withZip("Archive3.zip", listOf("normal", "../")) { root, zipRoot ->
testWalkFailsWithIllegalFileName(zipRoot)
val target = root.resolve("UnzipArchive3")
testCopyFailsWithIllegalFileName(zipRoot, target)
testDeleteFailsWith<IllegalFileNameException>(zipRoot)
}
withZip("Archive4.zip", listOf("normal", "..")) { root, zipRoot ->
testWalkFailsWithIllegalFileName(zipRoot)
val target = root.resolve("UnzipArchive4")
testCopyFailsWithIllegalFileName(zipRoot, target)
testDeleteFailsWith<IllegalFileNameException>(zipRoot)
}
withZip("Archive5.zip", listOf("normal", "../..")) { root, zipRoot ->
testWalkFailsWithIllegalFileName(zipRoot)
val target = root.resolve("UnzipArchive5")
testCopyFailsWithIllegalFileName(zipRoot, target)
testDeleteFailsWith<IllegalFileNameException>(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<IllegalFileNameException> {
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<NullPointerException>(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<IllegalFileNameException>(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<NoSuchFileException> {
zipRoot.walkIncludeDirectories().toList()
}
// There is no directory with name "a", thus empty walk sequence
testWalkSucceeds(zipRoot.resolve("a"), emptySet())
assertFailsWith<NoSuchFileException> {
val target = root.resolve("UnzipArchive1")
zipRoot.copyToRecursively(target, followLinks = false)
}
assertFailsWith<FileSystemException> {
zipRoot.deleteRecursively()
}.also { exception ->
exception.suppressed.forEach {
assertIs<NoSuchFileException>(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<NoSuchFileException>(zipRoot, target, target.resolve("", "b\\", "b\\d", "a\\", "a\\..\\b\\c"))
// Deleting a zip root throws NPE
testDeleteFailsWith<NullPointerException>(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<IllegalFileNameException>(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<FileSystemLoopException>(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<IllegalFileNameException>(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<FileSystemException>(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<Path>() // 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<NoSuchFileException>(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<FileSystemException>(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<FileSystemException>(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<Path>() // 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<NoSuchFileException>(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<FileSystemException>(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)
}
}
}
}
+15
View File
@@ -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)
}