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:
committed by
Space Team
parent
4c06673483
commit
f8c587ddcf
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user