From 90189f9c390ac66d2befc830bf13002cc69660b2 Mon Sep 17 00:00:00 2001 From: Abduqodiri Qurbonzoda Date: Tue, 2 Aug 2022 01:11:49 +0300 Subject: [PATCH] Provide Path.copyToRecursively() and Path.deleteRecursively() #KT-52928 --- .../src/kotlin/io/path/CopyActionContext.kt | 30 + .../src/kotlin/io/path/CopyActionResult.kt | 31 + .../jdk7/src/kotlin/io/path/OnErrorResult.kt | 28 + .../kotlin/io/path/PathRecursiveFunctions.kt | 417 ++++++++ .../jdk7/src/kotlin/io/path/PathTreeWalk.kt | 2 +- .../jdk7/src/kotlin/io/path/PathUtils.kt | 4 +- .../jdk7/test/PathRecursiveFunctionsTest.kt | 973 ++++++++++++++++++ .../kotlin-stdlib-runtime-merged.txt | 24 + 8 files changed, 1506 insertions(+), 3 deletions(-) create mode 100644 libraries/stdlib/jdk7/src/kotlin/io/path/CopyActionContext.kt create mode 100644 libraries/stdlib/jdk7/src/kotlin/io/path/CopyActionResult.kt create mode 100644 libraries/stdlib/jdk7/src/kotlin/io/path/OnErrorResult.kt create mode 100644 libraries/stdlib/jdk7/src/kotlin/io/path/PathRecursiveFunctions.kt create mode 100644 libraries/stdlib/jdk7/test/PathRecursiveFunctionsTest.kt diff --git a/libraries/stdlib/jdk7/src/kotlin/io/path/CopyActionContext.kt b/libraries/stdlib/jdk7/src/kotlin/io/path/CopyActionContext.kt new file mode 100644 index 00000000000..fa1a5da9685 --- /dev/null +++ b/libraries/stdlib/jdk7/src/kotlin/io/path/CopyActionContext.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2010-2022 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.io.path + +import java.nio.file.Path + +/** + * Context for the `copyAction` function passed to [Path.copyToRecursively]. + */ +@ExperimentalPathApi +@SinceKotlin("1.8") +public interface CopyActionContext { + /** + * Copies the entry located by this path to the specified [target] path, + * except if both this and [target] entries are directories, + * in which case the method completes without copying the entry. + * + * The entry is copied using `this.copyTo(target, *followLinksOption)`. See [kotlin.io.path.copyTo]. + * + * @param target the path to copy this entry to. + * @param followLinks `false` to copy the entry itself even if it's a symbolic link. + * `true` to copy its target if this entry is a symbolic link. + * If this entry is not a symbolic link, the value of this parameter doesn't make any difference. + * @return [CopyActionResult.CONTINUE] + */ + public fun Path.copyToIgnoringExistingDirectory(target: Path, followLinks: Boolean): CopyActionResult +} \ No newline at end of file diff --git a/libraries/stdlib/jdk7/src/kotlin/io/path/CopyActionResult.kt b/libraries/stdlib/jdk7/src/kotlin/io/path/CopyActionResult.kt new file mode 100644 index 00000000000..dd2b186e777 --- /dev/null +++ b/libraries/stdlib/jdk7/src/kotlin/io/path/CopyActionResult.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2010-2022 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.io.path + +import java.nio.file.Path + +/** + * The result of the `copyAction` function passed to [Path.copyToRecursively] that specifies further actions when copying an entry. + */ +@ExperimentalPathApi +@SinceKotlin("1.8") +public enum class CopyActionResult { + /** + * Continue with the next entry in the traversal order. + */ + CONTINUE, + + /** + * Skip the directory content, continue with the next entry outside the directory in the traversal order. + * For files this option is equivalent to [CONTINUE]. + */ + SKIP_SUBTREE, + + /** + * Stop the recursive copy function. The function will return without throwing exception. + */ + TERMINATE +} \ No newline at end of file diff --git a/libraries/stdlib/jdk7/src/kotlin/io/path/OnErrorResult.kt b/libraries/stdlib/jdk7/src/kotlin/io/path/OnErrorResult.kt new file mode 100644 index 00000000000..1e186228033 --- /dev/null +++ b/libraries/stdlib/jdk7/src/kotlin/io/path/OnErrorResult.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2010-2022 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.io.path + +import java.nio.file.Path + +/** + * The result of the `onError` function passed to [Path.copyToRecursively] that specifies further actions when an exception occurs. + */ +@ExperimentalPathApi +@SinceKotlin("1.8") +public enum class OnErrorResult { + /** + * If the entry that caused the error is a directory, skip the directory and its content, and + * continue with the next entry outside this directory in the traversal order. + * Otherwise, skip this entry and continue with the next entry in the traversal order. + */ + SKIP_SUBTREE, + + /** + * Stop the recursive copy function. The function will return without throwing exception. + * To terminate the function with an exception rethrow instead. + */ + TERMINATE +} \ No newline at end of file diff --git a/libraries/stdlib/jdk7/src/kotlin/io/path/PathRecursiveFunctions.kt b/libraries/stdlib/jdk7/src/kotlin/io/path/PathRecursiveFunctions.kt new file mode 100644 index 00000000000..1af535e0fb8 --- /dev/null +++ b/libraries/stdlib/jdk7/src/kotlin/io/path/PathRecursiveFunctions.kt @@ -0,0 +1,417 @@ +/* + * Copyright 2010-2022 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. + */ + +@file:JvmMultifileClass +@file:JvmName("PathsKt") + +package kotlin.io.path + +import java.io.IOException +import java.nio.file.* +import java.nio.file.FileSystemException +import java.nio.file.attribute.BasicFileAttributeView +import java.nio.file.attribute.BasicFileAttributes + +/** + * Recursively copies this directory and its content to the specified destination [target] path. + * Note that if this function throws, partial copying may have taken place. + * + * Unlike `File.copyRecursively`, if some directories on the way to the [target] are missing, then they won't be created automatically. + * You can use the following approach to ensure that required intermediate directories are created: + * ``` + * sourcePath.copyToRecursively( + * destinationPath.apply { parent?.createDirectories() }, + * followLinks = false + * ) + * ``` + * + * If the entry located by this path is a directory, this function recursively copies the directory itself and its content. + * Otherwise, this function copies only the entry. + * + * If an exception occurs attempting to read, open or copy any entry under the source subtree, + * further actions will depend on the result of the [onError] invoked with + * the source and destination paths, that caused the error, and the exception itself as arguments. + * If [onError] throws, this function ends immediately with the exception. + * By default [onError] rethrows the exception. See [OnErrorResult] for available options. + * + * This function performs "directory merge" operation. If an entry in the source subtree is a directory + * and the corresponding entry in the target subtree already exists and is also a directory, it does nothing. + * Otherwise, [overwrite] determines whether to overwrite existing destination entries. + * Attributes of a source entry, such as creation/modification date, are not copied. + * [followLinks] determines whether to copy a symbolic link itself or its target. + * Symbolic links in the target subtree are not followed. + * + * To provide a custom logic for copying use the overload that takes a `copyAction` lambda. + * + * @param target the destination path to copy recursively this entry to. + * @param onError the function that determines further actions if an error occurs. By default, rethrows the exception. + * @param followLinks `false` to copy a symbolic link itself, not its target. + * `true` to recursively copy the target of a symbolic link. + * @param overwrite `false` to throw if a destination entry already exists. + * `true` to overwrite existing destination entries. + * @throws NoSuchFileException if the entry located by this path does not exist. + * @throws FileSystemException if [target] is an entry inside the source subtree. + * @throws FileAlreadyExistsException if a destination entry already exists and [overwrite] is `false`. + * This exception is passed to [onError] for handling. + * @throws IOException if any errors occur while copying. + * This exception is passed to [onError] for handling. + * @throws SecurityException if a security manager is installed and access is not permitted to an entry in the source or target subtree. + * This exception is passed to [onError] for handling. + */ +@ExperimentalPathApi +@SinceKotlin("1.8") +public fun Path.copyToRecursively( + target: Path, + onError: (source: Path, target: Path, exception: Exception) -> OnErrorResult = { _, _, exception -> throw exception }, + followLinks: Boolean, + overwrite: Boolean +): Path { + return if (overwrite) { + copyToRecursively(target, onError, followLinks) { src, dst -> + val options = LinkFollowing.toLinkOptions(followLinks) + val dstIsDirectory = dst.isDirectory(LinkOption.NOFOLLOW_LINKS) + val srcIsDirectory = src.isDirectory(*options) + if ((srcIsDirectory && dstIsDirectory).not()) { + if (dstIsDirectory) + dst.deleteRecursively() + + src.copyTo(dst, *options, StandardCopyOption.REPLACE_EXISTING) + } + + // else: do nothing, the destination directory already exists + CopyActionResult.CONTINUE + } + } else { + copyToRecursively(target, onError, followLinks) + } +} + +/** + * Recursively copies this directory and its content to the specified destination [target] path. + * Note that if this function throws, partial copying may have taken place. + * + * Unlike `File.copyRecursively`, if some directories on the way to the [target] are missing, then they won't be created automatically. + * You can use the following approach to ensure that required intermediate directories are created: + * ``` + * sourcePath.copyToRecursively( + * destinationPath.apply { parent?.createDirectories() }, + * followLinks = false + * ) + * ``` + * + * If the entry located by this path is a directory, this function recursively copies the directory itself and its content. + * Otherwise, this function copies only the entry. + * + * If an exception occurs attempting to read, open or copy any entry under the source subtree, + * further actions will depend on the result of the [onError] invoked with + * the source and destination paths, that caused the error, and the exception itself as arguments. + * If [onError] throws, this function ends immediately with the exception. + * By default [onError] rethrows the exception. See [OnErrorResult] for available options. + * + * Copy operation is performed using [copyAction]. + * By default [copyAction] performs "directory merge" operation. If an entry in the source subtree is a directory + * and the corresponding entry in the target subtree already exists and is also a directory, it does nothing. + * Otherwise, the entry is copied using `sourcePath.copyTo(destinationPath, *followLinksOption)`, + * which doesn't copy attributes of the source entry and throws if the destination entry already exists. + * [followLinks] determines whether to copy a symbolic link itself or its target. + * Symbolic links in the target subtree are not followed. + * + * If a custom implementation of [copyAction] is provided, consider making it consistent with [followLinks] value. + * See [CopyActionResult] for available options. + * + * If [copyAction] throws an exception, it is passed to [onError] for handling. + * + * @param target the destination path to copy recursively this entry to. + * @param onError the function that determines further actions if an error occurs. By default, rethrows the exception. + * @param followLinks `false` to copy a symbolic link itself, not its target. + * `true` to recursively copy the target of a symbolic link. + * @param copyAction the function to call for copying source entries to their destination path rooted in [target]. + * By default, performs "directory merge" operation. + * @throws NoSuchFileException if the entry located by this path does not exist. + * @throws FileSystemException if [target] is an entry inside the source subtree. + * @throws IOException if any errors occur while copying. + * This exception is passed to [onError] for handling. + * @throws SecurityException if a security manager is installed and access is not permitted to an entry in the source or target subtree. + * This exception is passed to [onError] for handling. + */ +@ExperimentalPathApi +@SinceKotlin("1.8") +public fun Path.copyToRecursively( + target: Path, + onError: (source: Path, target: Path, exception: Exception) -> OnErrorResult = { _, _, exception -> throw exception }, + followLinks: Boolean, + copyAction: CopyActionContext.(source: Path, target: Path) -> CopyActionResult = { src, dst -> + src.copyToIgnoringExistingDirectory(dst, followLinks) + } +): Path { + if (!this.exists(*LinkFollowing.toLinkOptions(followLinks))) + throw NoSuchFileException(this.toString(), target.toString(), "The source file doesn't exist.") + + if (this.exists() && (followLinks || !this.isSymbolicLink())) { + // Here the checks are conducted without followLinks option, because: + // * isSameFileAs doesn't take LinkOption and throws if `this` or `other` file doesn't exist + // * toRealPath takes LinkOption, but the option also applies to the directories on the way to the file + // Thus links are always followed in isSameFileAs and toRealPath + + val targetExistsAndNotSymlink = target.exists() && !target.isSymbolicLink() + + if (targetExistsAndNotSymlink && this.isSameFileAs(target)) { + // TODO: KT-38678 + // source and target files are the same entry, continue recursive copy operation + } else { + val realPath = this.toRealPath() + val isSubdirectory = if (targetExistsAndNotSymlink) { + target.toRealPath().startsWith(realPath) + } else { + target.parent?.let { it.exists() && it.toRealPath().startsWith(realPath) } ?: false + } + if (isSubdirectory) + throw FileSystemException( + this.toString(), + target.toString(), + "Recursively copying a directory into its subdirectory is prohibited." + ) + } + } + + fun destination(source: Path): Path { + val relativePath = source.relativeTo(this@copyToRecursively) + return target.resolve(relativePath) + } + + fun error(source: Path, exception: Exception): FileVisitResult { + return onError(source, destination(source), exception).toFileVisitResult() + } + + @Suppress("UNUSED_PARAMETER") + fun copy(source: Path, attributes: BasicFileAttributes): FileVisitResult { + return try { + DefaultCopyActionContext.copyAction(source, destination(source)).toFileVisitResult() + } catch (exception: Exception) { + error(source, exception) + } + } + + visitFileTree(followLinks = followLinks) { + onPreVisitDirectory(::copy) + onVisitFile(::copy) + onVisitFileFailed(::error) + onPostVisitDirectory { directory, exception -> + if (exception == null) { + FileVisitResult.CONTINUE + } else { + error(directory, exception) + } + } + } + + return target +} + + +@ExperimentalPathApi +private object DefaultCopyActionContext : CopyActionContext { + override fun Path.copyToIgnoringExistingDirectory(target: Path, followLinks: Boolean): CopyActionResult { + val options = LinkFollowing.toLinkOptions(followLinks) + if (this.isDirectory(*options) && target.isDirectory(LinkOption.NOFOLLOW_LINKS)) { + // do nothing, the destination directory already exists + } else { + this.copyTo(target, *options) + } + + return CopyActionResult.CONTINUE + } +} + +@ExperimentalPathApi +private fun CopyActionResult.toFileVisitResult() = when (this) { + CopyActionResult.CONTINUE -> FileVisitResult.CONTINUE + CopyActionResult.TERMINATE -> FileVisitResult.TERMINATE + CopyActionResult.SKIP_SUBTREE -> FileVisitResult.SKIP_SUBTREE +} + +@ExperimentalPathApi +private fun OnErrorResult.toFileVisitResult() = when (this) { + OnErrorResult.TERMINATE -> FileVisitResult.TERMINATE + OnErrorResult.SKIP_SUBTREE -> FileVisitResult.SKIP_SUBTREE +} + +/** + * Recursively deletes this directory and its content. + * Note that if this function throws, partial deletion may have taken place. + * + * If the entry located by this path is a directory, this function recursively deletes its content and the directory itself. + * Otherwise, this function deletes only the entry. + * Symbolic links are not followed to their targets. + * This function does nothing if the entry located by this path does not exist. + * + * If the underlying platform supports [SecureDirectoryStream], + * traversal of the file tree and removal of entries are performed using it. + * Otherwise, directories in the file tree are opened with the less secure [Files.newDirectoryStream]. + * Note that on a platform that supports symbolic links and does not support [SecureDirectoryStream], + * it is possible for a recursive delete to delete files and directories that are outside the directory being deleted. + * This can happen if, after checking that an entry is a directory (and not a symbolic link), that directory is replaced + * by a symbolic link to an outside directory before the call that opens the directory to read its entries. + * + * If an exception occurs attempting to read, open or delete any entry under the given file tree, + * this method skips that entry and continues. Such exceptions are collected and, after attempting to delete all entries, + * an [IOException] is thrown containing those exceptions as suppressed exceptions. + * Maximum of `64` exceptions are collected. After reaching that amount, thrown exceptions are ignored and not collected. + * + * @throws IOException if any entry in the file tree can't be deleted for any reason. + */ +@ExperimentalPathApi +@SinceKotlin("1.8") +public fun Path.deleteRecursively(): Unit { + val suppressedExceptions = this.deleteRecursivelyImpl() + + if (suppressedExceptions.isNotEmpty()) { + throw FileSystemException("Failed to delete one or more files. See suppressed exceptions for details.").apply { + suppressedExceptions.forEach { addSuppressed(it) } + } + } +} + +private class ExceptionsCollector(private val limit: Int = 64) { + var totalExceptions: Int = 0 + private set + + val collectedExceptions = mutableListOf() + + var path: Path? = null + + fun enterEntry(name: Path) { + path = path?.resolve(name) + } + + fun exitEntry(name: Path) { + require(name == path?.fileName) + path = path?.parent + } + + fun collect(exception: Exception) { + totalExceptions += 1 + val shouldCollect = collectedExceptions.size < limit + if (shouldCollect) { + val restoredException = if (path != null) { + // When SecureDirectoryStream is used, only entry name gets reported in exception message. + // Thus, wrap such exceptions in FileSystemException with restored path. + FileSystemException(path.toString()).initCause(exception) as FileSystemException + } else { + exception + } + collectedExceptions.add(restoredException) + } + } +} + +private fun Path.deleteRecursivelyImpl(): List { + val collector = ExceptionsCollector() + var useInsecure = true + + // TODO: KT-54077 + this.parent?.let { parent -> + val directoryStream = try { Files.newDirectoryStream(parent) } catch (_: Throwable) { null } + directoryStream?.use { stream -> + if (stream is SecureDirectoryStream) { + useInsecure = false + collector.path = parent + stream.handleEntry(this.fileName, collector) + } + } + } + + if (useInsecure) { + insecureHandleEntry(this, collector) + } + + return collector.collectedExceptions +} + +private inline fun collectIfThrows(collector: ExceptionsCollector, function: () -> Unit) { + try { + function() + } catch (exception: Exception) { + collector.collect(exception) + } +} + +private inline fun tryIgnoreNoSuchFileException(function: () -> R): R? { + return try { function() } catch (_: NoSuchFileException) { null } +} + +// secure walk + +private fun SecureDirectoryStream.handleEntry(name: Path, collector: ExceptionsCollector) { + collector.enterEntry(name) + + collectIfThrows(collector) { + if (this.isDirectory(name, LinkOption.NOFOLLOW_LINKS)) { + val preEnterTotalExceptions = collector.totalExceptions + + this.enterDirectory(name, collector) + + // If something went wrong trying to delete the contents of the + // directory, don't try to delete the directory as it will probably fail. + if (preEnterTotalExceptions == collector.totalExceptions) { + tryIgnoreNoSuchFileException { this.deleteDirectory(name) } + } + } else { + tryIgnoreNoSuchFileException { this.deleteFile(name) } // deletes symlink itself, not its target + } + } + + collector.exitEntry(name) +} + +private fun SecureDirectoryStream.enterDirectory(name: Path, collector: ExceptionsCollector) { + collectIfThrows(collector) { + tryIgnoreNoSuchFileException { + this.newDirectoryStream(name, LinkOption.NOFOLLOW_LINKS) + }?.use { directoryStream -> + for (entry in directoryStream) { + directoryStream.handleEntry(entry.fileName, collector) + } + } + } +} + +private fun SecureDirectoryStream.isDirectory(entryName: Path, vararg options: LinkOption): Boolean { + return tryIgnoreNoSuchFileException { + this.getFileAttributeView(entryName, BasicFileAttributeView::class.java, *options).readAttributes().isDirectory + } ?: false +} + +// insecure walk + +private fun insecureHandleEntry(entry: Path, collector: ExceptionsCollector) { + collectIfThrows(collector) { + if (entry.isDirectory(LinkOption.NOFOLLOW_LINKS)) { + val preEnterTotalExceptions = collector.totalExceptions + + insecureEnterDirectory(entry, collector) + + // If something went wrong trying to delete the contents of the + // directory, don't try to delete the directory as it will probably fail. + if (preEnterTotalExceptions == collector.totalExceptions) { + entry.deleteIfExists() + } + } else { + entry.deleteIfExists() // deletes symlink itself, not its target + } + } +} + +private fun insecureEnterDirectory(path: Path, collector: ExceptionsCollector) { + collectIfThrows(collector) { + tryIgnoreNoSuchFileException { + Files.newDirectoryStream(path) + }?.use { directoryStream -> + for (entry in directoryStream) { + insecureHandleEntry(entry, collector) + } + } + } +} diff --git a/libraries/stdlib/jdk7/src/kotlin/io/path/PathTreeWalk.kt b/libraries/stdlib/jdk7/src/kotlin/io/path/PathTreeWalk.kt index 7abf5771f14..79580e58b30 100644 --- a/libraries/stdlib/jdk7/src/kotlin/io/path/PathTreeWalk.kt +++ b/libraries/stdlib/jdk7/src/kotlin/io/path/PathTreeWalk.kt @@ -137,7 +137,7 @@ private fun PathNode.createsCycle(): Boolean { } -private object LinkFollowing { +internal object LinkFollowing { private val nofollowLinkOption = arrayOf(LinkOption.NOFOLLOW_LINKS) private val followLinkOption = emptyArray() diff --git a/libraries/stdlib/jdk7/src/kotlin/io/path/PathUtils.kt b/libraries/stdlib/jdk7/src/kotlin/io/path/PathUtils.kt index cfe6aee2d1b..ce58fc03f4d 100644 --- a/libraries/stdlib/jdk7/src/kotlin/io/path/PathUtils.kt +++ b/libraries/stdlib/jdk7/src/kotlin/io/path/PathUtils.kt @@ -199,7 +199,7 @@ private object PathRelativizer { * When [overwrite] is `true` and [target] is a directory, it is replaced only if it is empty. * * If this path is a directory, it is copied without its content, i.e. an empty [target] directory is created. - * If you want to copy directory including its contents, use [copyRecursively]. + * If you want to copy directory including its contents, use [copyToRecursively]. * * The operation doesn't preserve copied file attributes such as creation/modification date, permissions, etc. * @@ -238,7 +238,7 @@ public inline fun Path.copyTo(target: Path, overwrite: Boolean = false): Path { * it is replaced only if it is empty. * * If this path is a directory, it is copied *without* its content, i.e. an empty [target] directory is created. - * If you want to copy a directory including its contents, use [copyRecursively]. + * If you want to copy a directory including its contents, use [copyToRecursively]. * * The operation doesn't preserve copied file attributes such as creation/modification date, * permissions, etc. unless [COPY_ATTRIBUTES][StandardCopyOption.COPY_ATTRIBUTES] is used. diff --git a/libraries/stdlib/jdk7/test/PathRecursiveFunctionsTest.kt b/libraries/stdlib/jdk7/test/PathRecursiveFunctionsTest.kt new file mode 100644 index 00000000000..31334f88358 --- /dev/null +++ b/libraries/stdlib/jdk7/test/PathRecursiveFunctionsTest.kt @@ -0,0 +1,973 @@ +/* + * Copyright 2010-2021 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 java.nio.file.* +import kotlin.io.path.* +import kotlin.jdk7.test.PathTreeWalkTest.Companion.createTestFiles +import kotlin.jdk7.test.PathTreeWalkTest.Companion.referenceFilenames +import kotlin.jdk7.test.PathTreeWalkTest.Companion.referenceFilesOnly +import kotlin.jdk7.test.PathTreeWalkTest.Companion.testVisitedFiles +import kotlin.test.* + +class PathRecursiveFunctionsTest : AbstractPathTest() { + @Test + fun deleteFile() { + val file = createTempFile() + + assertTrue(file.exists()) + file.deleteRecursively() + assertFalse(file.exists()) + + file.createFile().writeText("non-empty file") + + assertTrue(file.exists()) + file.deleteRecursively() + assertFalse(file.exists()) + file.deleteRecursively() // successfully deletes recursively a non-existent file + } + + @Test + fun deleteDirectory() { + val dir = createTestFiles() + + assertTrue(dir.exists()) + dir.deleteRecursively() + assertFalse(dir.exists()) + dir.deleteRecursively() // successfully deletes recursively a non-existent directory + } + + @Test + fun deleteNotExistingParent() { + val basedir = createTempDirectory().cleanupRecursively() + basedir.resolve("a/b").deleteRecursively() + basedir.resolve("a/b/c").deleteRecursively() + } + + private fun Path.walkIncludeDirectories(): Sequence = + this.walk(PathWalkOption.INCLUDE_DIRECTORIES) + + @Test + fun deleteRestrictedRead() { + val basedir = createTestFiles().cleanupRecursively() + val restrictedEmptyDir = basedir.resolve("6") + val restrictedDir = basedir.resolve("1") + val restrictedFile = basedir.resolve("7.txt") + + withRestrictedRead(restrictedEmptyDir, restrictedDir, restrictedFile) { + val error = assertFailsWith("Expected incomplete recursive deletion") { + basedir.deleteRecursively() + } + + // AccessDeniedException when opening restrictedEmptyDir and restrictedDir, wrapped in FileSystemException if SecureDirectoryStream was used + // DirectoryNotEmptyException is not thrown from parent directory + assertEquals(2, error.suppressedExceptions.size) + assertIs(error.suppressedExceptions[0].let { it.cause ?: it }) + assertIs(error.suppressedExceptions[1].let { it.cause ?: it }) + + // Couldn't read directory entries. + // No attempt to delete even when empty directories can be removed without write permission + assertTrue(restrictedEmptyDir.exists()) + assertTrue(restrictedDir.exists()) // couldn't read directory entries + assertFalse(restrictedFile.exists()) // restricted read allows removal of file + + restrictedEmptyDir.toFile().setReadable(true) + restrictedDir.toFile().setReadable(true) + testVisitedFiles(listOf("", "1", "1/2", "1/3", "1/3/4.txt", "1/3/5.txt", "6"), basedir.walkIncludeDirectories(), basedir) + basedir.deleteRecursively() + } + } + + @Test + fun deleteRestrictedWrite() { + val basedir = createTestFiles().cleanupRecursively() + val restrictedEmptyDir = basedir.resolve("6") + val restrictedDir = basedir.resolve("8") + val restrictedFile = basedir.resolve("1/3/5.txt") + + withRestrictedWrite(restrictedEmptyDir, restrictedDir, restrictedFile) { + val error = assertFailsWith("Expected incomplete recursive deletion") { + basedir.deleteRecursively() + } + + // AccessDeniedException when deleting "8/9.txt", wrapped in FileSystemException if SecureDirectoryStream was used + // DirectoryNotEmptyException is not thrown from parent directories + when (val accessDenied = error.suppressedExceptions.single()) { + is java.nio.file.AccessDeniedException -> { + assertEquals(restrictedDir.resolve("9.txt").toString(), accessDenied.file) + } + is java.nio.file.FileSystemException -> { + assertEquals(restrictedDir.resolve("9.txt").toString(), accessDenied.file) + assertIs(accessDenied.cause) + } + else -> { + fail("Unexpected exception $accessDenied") + } + } + + assertFalse(restrictedEmptyDir.exists()) // empty directories can be removed without write permission + assertTrue(restrictedDir.exists()) + assertTrue(restrictedDir.resolve("9.txt").exists()) + assertFalse(restrictedFile.exists()) // plain files can be removed without write permission + } + } + + @Test + fun deleteBaseSymlinkToFile() { + val file = createTempFile().cleanup() + val link = createTempDirectory().cleanupRecursively().resolve("link").tryCreateSymbolicLinkTo(file) ?: return + + link.deleteRecursively() + assertFalse(link.exists(LinkOption.NOFOLLOW_LINKS)) + assertTrue(file.exists()) + } + + @Test + fun deleteBaseSymlinkToDirectory() { + val dir = createTestFiles().cleanupRecursively() + val link = createTempDirectory().cleanupRecursively().resolve("link").tryCreateSymbolicLinkTo(dir) ?: return + + link.deleteRecursively() + assertFalse(link.exists(LinkOption.NOFOLLOW_LINKS)) + testVisitedFiles(listOf("") + referenceFilenames, dir.walkIncludeDirectories(), dir) + } + + @Test + fun deleteSymlinkToFile() { + val file = createTempFile().cleanup() + val dir = createTestFiles().cleanupRecursively().also { it.resolve("8/link").tryCreateSymbolicLinkTo(file) ?: return } + + dir.deleteRecursively() + assertFalse(dir.exists()) + assertTrue(file.exists()) + } + + @Test + fun deleteSymlinkToDirectory() { + val dir1 = createTestFiles().cleanupRecursively() + val dir2 = createTestFiles().cleanupRecursively().also { it.resolve("8/link").tryCreateSymbolicLinkTo(dir1) ?: return } + + dir2.deleteRecursively() + assertFalse(dir2.exists()) + testVisitedFiles(listOf("") + referenceFilenames, dir1.walkIncludeDirectories(), dir1) + } + + @Test + fun deleteParentSymlink() { + val dir1 = createTestFiles().cleanupRecursively() + val dir2 = createTempDirectory().cleanupRecursively().also { it.resolve("link").tryCreateSymbolicLinkTo(dir1) ?: return } + + dir2.resolve("link/8").deleteRecursively() + assertFalse(dir1.resolve("8").exists()) + + dir2.resolve("link/1/3").deleteRecursively() + assertFalse(dir1.resolve("1/3").exists()) + } + + @Test + fun deleteSymlinkToSymlink() { + val dir = createTestFiles().cleanupRecursively() + val link = createTempDirectory().cleanupRecursively().resolve("link").tryCreateSymbolicLinkTo(dir) ?: return + val linkToLink = createTempDirectory().cleanupRecursively().resolve("linkToLink").tryCreateSymbolicLinkTo(link) ?: return + + linkToLink.deleteRecursively() + assertFalse(linkToLink.exists(LinkOption.NOFOLLOW_LINKS)) + assertTrue(link.exists(LinkOption.NOFOLLOW_LINKS)) + testVisitedFiles(listOf("") + referenceFilenames, dir.walkIncludeDirectories(), dir) + } + + @Test + fun deleteSymlinkCyclic() { + val basedir = createTestFiles().cleanupRecursively() + val original = basedir.resolve("1") + original.resolve("2/link").tryCreateSymbolicLinkTo(original) ?: return + + basedir.deleteRecursively() + assertFalse(basedir.exists()) + } + + @Test + fun deleteSymlinkCyclicWithTwo() { + val basedir = createTestFiles().cleanupRecursively() + val dir8 = basedir.resolve("8") + val dir2 = basedir.resolve("1/2") + dir8.resolve("linkTo2").tryCreateSymbolicLinkTo(dir2) ?: return + dir2.resolve("linkTo8").tryCreateSymbolicLinkTo(dir8) ?: return + + basedir.deleteRecursively() + assertFalse(basedir.exists()) + } + + @Test + fun deleteSymlinkPointingToItself() { + val basedir = createTempDirectory().cleanupRecursively() + val link = basedir.resolve("link") + link.tryCreateSymbolicLinkTo(link) ?: return + + basedir.deleteRecursively() + assertFalse(basedir.exists()) + } + + @Test + fun deleteSymlinkTwoPointingToEachOther() { + val basedir = createTempDirectory().cleanupRecursively() + val link1 = basedir.resolve("link1") + val link2 = basedir.resolve("link2").tryCreateSymbolicLinkTo(link1) ?: return + link1.tryCreateSymbolicLinkTo(link2) ?: return + + basedir.deleteRecursively() + assertFalse(basedir.exists()) + } + + private fun compareFiles(src: Path, dst: Path, message: String? = null) { + assertTrue(dst.exists()) + assertEquals(src.isRegularFile(), dst.isRegularFile(), message) + assertEquals(src.isDirectory(), dst.isDirectory(), message) + if (dst.isRegularFile()) { + assertTrue(src.readBytes().contentEquals(dst.readBytes()), message) + } + } + + private fun compareDirectories(src: Path, dst: Path) { + for (srcFile in src.walkIncludeDirectories()) { + val dstFile = dst.resolve(srcFile.relativeTo(src)) + compareFiles(srcFile, dstFile) + } + } + + @Test + fun copyFileToFile() { + val src = createTempFile().cleanup().also { it.writeText("hello") } + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + val copyResult = src.copyToRecursively(dst, followLinks = false) + assertEquals(dst, copyResult) + compareFiles(src, dst) + + dst.writeText("bye") + assertFailsWith { + src.copyToRecursively(dst, followLinks = false) + } + assertEquals("bye", dst.readText()) + + src.copyToRecursively(dst, followLinks = false, overwrite = true) + compareFiles(src, dst) + } + + @Test + fun copyFileToDirectory() { + val src = createTempFile().cleanup().also { it.writeText("hello") } + val dst = createTestFiles().cleanupRecursively() + + assertFailsWith { + src.copyToRecursively(dst, followLinks = false) + } + assertTrue(dst.isDirectory()) + + assertFailsWith { + src.copyToRecursively(dst, followLinks = false) { source, target -> + source.copyTo(target, overwrite = true) + CopyActionResult.CONTINUE + } + } + assertTrue(dst.isDirectory()) + + val copyResult = src.copyToRecursively(dst, followLinks = false, overwrite = true) + assertEquals(dst, copyResult) + compareFiles(src, dst) + } + + private fun Path.relativePathString(base: Path): String { + return relativeToOrSelf(base).invariantSeparatorsPathString + } + + @Test + fun copyDirectoryToDirectory() { + val src = createTestFiles().cleanupRecursively() + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + val copyResult = src.copyToRecursively(dst, followLinks = false) + assertEquals(dst, copyResult) + compareDirectories(src, dst) + + src.resolve("1/3/4.txt").writeText("hello") + dst.resolve("10").createDirectory() + + val conflictingFiles = mutableListOf() + src.copyToRecursively(dst, followLinks = false, onError = { source, _, exception -> + assertIs(exception) + conflictingFiles.add(source.relativePathString(src)) + OnErrorResult.SKIP_SUBTREE + }) + assertEquals(referenceFilesOnly.sorted(), conflictingFiles.sorted()) + assertTrue(dst.resolve("1/3/4.txt").readText().isEmpty()) + + src.copyToRecursively(dst, followLinks = false, overwrite = true) + compareDirectories(src, dst) + assertTrue(dst.resolve("10").exists()) + } + + @Test + fun copyDirectoryToFile() { + val src = createTestFiles().cleanupRecursively() + val dst = createTempFile().cleanupRecursively().also { it.writeText("hello") } + + val existsException = assertFailsWith { + src.copyToRecursively(dst, followLinks = false) + } + // attempted to copy only the root directory(src) + assertEquals(dst.toString(), existsException.file) + assertTrue(dst.isRegularFile()) + + src.copyToRecursively(dst, followLinks = false, overwrite = true) + compareDirectories(src, dst) + } + + @Test + fun copyNonExistentSource() { + val src = createTempDirectory().also { it.deleteExisting() } + val dst = createTempDirectory() + + assertFailsWith { + src.copyToRecursively(dst, followLinks = false) + } + + dst.deleteExisting() + assertFailsWith { + src.copyToRecursively(dst, followLinks = false) + } + } + + @Test + fun copyNonExistentDestinationParent() { + val src = createTempDirectory().cleanupRecursively() + val dst = createTempDirectory().cleanupRecursively().resolve("parent/dst") + + assertFalse(dst.parent.exists()) + + src.copyToRecursively(dst, followLinks = false, onError = { source, target, exception -> + assertIs(exception) + assertEquals(src, source) + assertEquals(dst, target) + assertEquals(dst.toString(), exception.file) + OnErrorResult.SKIP_SUBTREE + }) + + src.copyToRecursively(dst.apply { parent?.createDirectories() }, followLinks = false) + } + + @Test + fun copyRestrictedReadInSource() { + val src = createTestFiles().cleanupRecursively() + val dst = createTempDirectory().cleanupRecursively() + + val restrictedDir = src.resolve("1/3") + val restrictedFile = src.resolve("7.txt") + + withRestrictedRead(restrictedDir, restrictedFile, alsoReset = listOf(dst.resolve("1/3"), dst.resolve("7.txt"))) { + // Restricted directories fail during traversal, while files fail when copied. + // Because Files.walkFileTree opens a directory before calling FileVisitor.onPreVisitDirectory with it. + src.copyToRecursively(dst, followLinks = false, onError = { source, _, exception -> + assertIs(exception) + assertEquals(source.toString(), exception.file) + assertEquals("1/3", source.relativePathString(src)) + OnErrorResult.SKIP_SUBTREE + }) { source, target -> + try { + source.copyToIgnoringExistingDirectory(target, followLinks = false) + } catch (exception: Throwable) { + assertIs(exception) + assertEquals(source.toString(), exception.file) + assertEquals("7.txt", source.relativePathString(src)) + } + CopyActionResult.CONTINUE + } + + assertFalse(dst.resolve("1/3").exists()) // restricted directory is not copied + assertFalse(dst.resolve("7.txt").exists()) // restricted file is not copied + } + } + + @Test + fun copyRestrictedWriteInSource() { + val src = createTestFiles().cleanupRecursively() + val dst = createTempDirectory().cleanupRecursively() + + val restrictedDir = src.resolve("1/3") + val restrictedFile = src.resolve("7.txt") + + withRestrictedWrite(restrictedDir, restrictedFile, alsoReset = listOf(dst.resolve("1/3"), dst.resolve("7.txt"))) { + val accessDeniedFiles = mutableListOf() + src.copyToRecursively(dst, followLinks = false, onError = { _, target, exception -> + assertIs(exception) + assertEquals(target.toString(), exception.file) + accessDeniedFiles.add(target.relativePathString(dst)) + OnErrorResult.SKIP_SUBTREE + }) + assertEquals(listOf("1/3/4.txt", "1/3/5.txt"), accessDeniedFiles.sorted()) + + assertTrue(dst.resolve("1/3").exists()) // restricted directory is copied + assertFalse(dst.resolve("1/3").isWritable()) // access permissions are copied + assertTrue(dst.resolve("7.txt").exists()) // restricted file is copied + assertFalse(dst.resolve("7.txt").isWritable()) // access permissions are copied + } + } + + @Test + fun copyRestrictedWriteInDestination() { + val src = createTestFiles().cleanupRecursively() + val dst = createTestFiles().cleanupRecursively() + + src.resolve("1/3/4.txt").writeText("hello") + src.resolve("7.txt").writeText("hi") + + val restrictedDir = dst.resolve("1/3") + val restrictedFile = dst.resolve("7.txt") + + withRestrictedWrite(restrictedDir, restrictedFile) { + val accessDeniedFiles = mutableListOf() + src.copyToRecursively(dst, followLinks = false, overwrite = true, onError = { _, target, exception -> + assertIs(exception) + assertEquals(target.toString(), exception.file) + accessDeniedFiles.add(target.relativePathString(dst)) + OnErrorResult.SKIP_SUBTREE + }) + assertEquals(listOf("1/3/4.txt", "1/3/5.txt"), accessDeniedFiles.sorted()) + + assertNotEquals(src.resolve("1/3/4.txt").readText(), dst.resolve("1/3/4.txt").readText()) + assertEquals(src.resolve("7.txt").readText(), dst.resolve("7.txt").readText()) + } + } + + @Test + fun copyBrokenBaseSymlink() { + val basedir = createTempDirectory().cleanupRecursively() + val target = basedir.resolve("target") + val link = basedir.resolve("link").tryCreateSymbolicLinkTo(target) ?: return + val dst = basedir.resolve("dst") + + // the same behavior as link.copyTo(dst, LinkOption.NOFOLLOW_LINKS) + link.copyToRecursively(dst, followLinks = false) + assertTrue(dst.isSymbolicLink()) + assertTrue(dst.exists(LinkOption.NOFOLLOW_LINKS)) + assertFalse(dst.exists()) + + assertFailsWith { + link.copyToRecursively(dst, followLinks = false) + } + + // the same behavior as link.copyTo(dst) + dst.deleteExisting() + assertFailsWith { + link.copyToRecursively(dst, followLinks = true) + } + assertFalse(dst.exists(LinkOption.NOFOLLOW_LINKS)) + } + + @Test + fun copyBrokenSymlink() { + val src = createTestFiles().cleanupRecursively() + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + val target = createTempDirectory().cleanupRecursively().resolve("target") + src.resolve("8/link").tryCreateSymbolicLinkTo(target) ?: return + val dstLink = dst.resolve("8/link") + + // the same behavior as link.copyTo(dst, LinkOption.NOFOLLOW_LINKS) + src.copyToRecursively(dst, followLinks = false) + assertTrue(dstLink.isSymbolicLink()) + assertTrue(dstLink.exists(LinkOption.NOFOLLOW_LINKS)) + assertFalse(dstLink.exists()) + + // the same behavior as link.copyTo(dst) + dst.deleteRecursively() + assertFailsWith { + src.copyToRecursively(dst, followLinks = true) + } + assertFalse(dstLink.exists(LinkOption.NOFOLLOW_LINKS)) + } + + @Test + fun copyBaseSymlinkPointingToFile() { + val src = createTempFile().cleanup().also { it.writeText("hello") } + val link = createTempDirectory().cleanupRecursively().resolve("link").tryCreateSymbolicLinkTo(src) ?: return + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + link.copyToRecursively(dst, followLinks = false) + compareFiles(link, dst) + + dst.deleteExisting() + + link.copyToRecursively(dst, followLinks = true) + compareFiles(src, dst) + } + + @Test + fun copyBaseSymlinkPointingToDirectory() { + val src = createTestFiles().cleanupRecursively() + val link = createTempDirectory().cleanupRecursively().resolve("link").tryCreateSymbolicLinkTo(src) ?: return + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + link.copyToRecursively(dst, followLinks = false) + compareFiles(link, dst) + + dst.deleteExisting() + + link.copyToRecursively(dst, followLinks = true) + compareDirectories(src, dst) + } + + @Test + fun copySymlinkPointingToDirectory() { + val symlinkTarget = createTestFiles().cleanupRecursively() + val src = createTestFiles().cleanupRecursively().also { it.resolve("8/link").tryCreateSymbolicLinkTo(symlinkTarget) ?: return } + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + src.copyToRecursively(dst, followLinks = false) + val srcContent = listOf("", "8/link") + referenceFilenames + testVisitedFiles(srcContent, dst.walkIncludeDirectories(), dst) + + dst.deleteRecursively() + + src.copyToRecursively(dst, followLinks = true) + val expectedDstContent = srcContent + referenceFilenames.map { "8/link/$it" } + testVisitedFiles(expectedDstContent, dst.walkIncludeDirectories(), dst) + } + + @Test + fun copyIgnoreExistingDirectoriesFollowLinks() { + val src = createTestFiles().cleanupRecursively() + val symlinkTarget = createTempDirectory().cleanupRecursively() + val dst = createTempDirectory().cleanupRecursively().also { + it.resolve("1").createDirectory() + it.resolve("1/3").tryCreateSymbolicLinkTo(symlinkTarget) ?: return + } + + src.copyToRecursively(dst, followLinks = true, onError = { source, target, exception -> + assertIs(exception) + assertEquals(src.resolve("1/3"), source) + assertEquals(dst.resolve("1/3"), target) + assertEquals(target.toString(), exception.file) + OnErrorResult.SKIP_SUBTREE + }) + assertTrue(dst.resolve("1/3").isSymbolicLink()) + assertTrue(symlinkTarget.listDirectoryEntries().isEmpty()) + + src.copyToRecursively(dst, followLinks = true, overwrite = true) + assertFalse(dst.resolve("1/3").isSymbolicLink()) + assertTrue(symlinkTarget.listDirectoryEntries().isEmpty()) + } + + @Test + fun copyIgnoreExistingDirectoriesNoFollowLinks() { + val src = createTestFiles().cleanupRecursively() + val symlinkTarget = createTempDirectory().cleanupRecursively() + val dst = createTempDirectory().cleanupRecursively().also { + it.resolve("1").createDirectory() + it.resolve("1/3").tryCreateSymbolicLinkTo(symlinkTarget) ?: return + } + + src.copyToRecursively(dst, followLinks = false, onError = { source, target, exception -> + assertIs(exception) + assertEquals(src.resolve("1/3"), source) + assertEquals(dst.resolve("1/3"), target) + assertEquals(target.toString(), exception.file) + OnErrorResult.SKIP_SUBTREE + }) + assertTrue(dst.resolve("1/3").isSymbolicLink()) + assertTrue(symlinkTarget.listDirectoryEntries().isEmpty()) + + src.copyToRecursively(dst, followLinks = false, overwrite = true) + assertFalse(dst.resolve("1/3").isSymbolicLink()) + assertTrue(symlinkTarget.listDirectoryEntries().isEmpty()) + } + + @Test + fun copyParentSymlink() { + val source = createTestFiles().cleanupRecursively() + val linkToSource = createTempDirectory().cleanupRecursively().resolve("link").tryCreateSymbolicLinkTo(source) ?: return + val sources = listOf( + source to referenceFilenames, + linkToSource.resolve("8") to listOf("9.txt"), + linkToSource.resolve("1/3") to listOf("4.txt", "5.txt") + ) + + for ((src, srcContent) in sources) { + for (followLinks in listOf(false, true)) { + val target = createTempDirectory().cleanupRecursively().also { it.resolve("a/b").createDirectories() } + val linkToTarget = createTempDirectory().cleanupRecursively().resolve("link").tryCreateSymbolicLinkTo(target) ?: return + val targets = listOf( + target to listOf("a", "a/b"), + linkToTarget.resolve("a") to listOf("b"), + linkToTarget.resolve("a/b") to listOf() + ) + + for ((dst, dstContent) in targets) { + src.copyToRecursively(dst, followLinks = followLinks) + val expectedDstContent = listOf("") + dstContent + srcContent + testVisitedFiles(expectedDstContent, dst.walkIncludeDirectories(), dst) + } + } + } + } + + @Test + fun copySymlinkToSymlink() { + val src = createTestFiles().cleanupRecursively() + val link = createTempDirectory().cleanupRecursively().resolve("link").tryCreateSymbolicLinkTo(src) ?: return + val linkToLink = createTempDirectory().cleanupRecursively().resolve("linkToLink").tryCreateSymbolicLinkTo(link) ?: return + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + linkToLink.copyToRecursively(dst, followLinks = true) + testVisitedFiles(listOf("") + referenceFilenames, dst.walkIncludeDirectories(), dst) + } + + @Test + fun copySymlinkCyclic() { + val src = createTestFiles().cleanupRecursively() + val original = src.resolve("1") + original.resolve("2/link").tryCreateSymbolicLinkTo(original) ?: return + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + src.copyToRecursively(dst, followLinks = true, onError = { source, _, exception -> + assertIs(exception) + assertEquals(src.resolve("1/2/link"), source) + assertEquals(source.toString(), exception.file) + OnErrorResult.SKIP_SUBTREE + }) + + // partial copy, only "1/2/link" is not copied + testVisitedFiles(listOf("") + referenceFilenames, dst.walkIncludeDirectories(), dst) + } + + @Test + fun copySymlinkCyclicWithTwo() { + val src = createTestFiles().cleanupRecursively() + val dir8 = src.resolve("8") + val dir2 = src.resolve("1/2") + dir8.resolve("linkTo2").tryCreateSymbolicLinkTo(dir2) ?: return + dir2.resolve("linkTo8").tryCreateSymbolicLinkTo(dir8) ?: return + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + val loops = mutableListOf() + src.copyToRecursively(dst, followLinks = true, onError = { source, _, exception -> + assertIs(exception) + assertEquals(source.toString(), exception.file) + loops.add(source.relativePathString(src)) + OnErrorResult.SKIP_SUBTREE + }) + assertEquals(listOf("1/2/linkTo8/linkTo2", "8/linkTo2/linkTo8"), loops.sorted()) + + // partial copy, only "1/2/linkTo8/linkTo2" and "8/linkTo2/linkTo8" are not copied + val expected = listOf("", "1/2/linkTo8", "1/2/linkTo8/9.txt", "8/linkTo2") + referenceFilenames + testVisitedFiles(expected, dst.walkIncludeDirectories(), dst) + } + + @Test + fun copySymlinkPointingToItself() { + val src = createTempDirectory().cleanupRecursively() + val link = src.resolve("link") + link.tryCreateSymbolicLinkTo(link) ?: return + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + assertFailsWith { + // throws with message "Too many levels of symbolic links" + src.copyToRecursively(dst, followLinks = true) + } + } + + @Test + fun copySymlinkTwoPointingToEachOther() { + val src = createTempDirectory().cleanupRecursively() + val link1 = src.resolve("link1") + val link2 = src.resolve("link2").tryCreateSymbolicLinkTo(link1) ?: return + link1.tryCreateSymbolicLinkTo(link2) ?: return + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + assertFailsWith { + // throws with message "Too many levels of symbolic links" + src.copyToRecursively(dst, followLinks = true) + } + } + + @Test + fun copyWithNestedCopyToRecursively() { + val src = createTestFiles().cleanupRecursively() + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + val nested = createTestFiles().cleanupRecursively() + + src.copyToRecursively(dst, followLinks = false) { source, target -> + if (source.name == "2") { + nested.copyToRecursively(target, followLinks = false) + } else { + source.copyToIgnoringExistingDirectory(target, followLinks = false) + } + CopyActionResult.CONTINUE + } + + val expected = listOf("") + referenceFilenames + referenceFilenames.map { "1/2/$it" } + testVisitedFiles(expected, dst.walkIncludeDirectories(), dst) + } + + @Test + fun copyWithSkipSubtree() { + val src = createTestFiles().cleanupRecursively() + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + src.copyToRecursively(dst, followLinks = false) { source, target -> + source.copyToIgnoringExistingDirectory(target, followLinks = false) + if (source.name == "3" || source.name == "9.txt") { + CopyActionResult.SKIP_SUBTREE + } else { + CopyActionResult.CONTINUE + } + } + + // both "3" and "9.txt" are copied + val copied3 = dst.resolve("1/3").exists() + val copied9 = dst.resolve("8/9.txt").exists() + assertTrue(copied3 && copied9) + + // content of "3" is not copied + assertTrue(dst.resolve("1/3").listDirectoryEntries().isEmpty()) + } + + @Test + fun copyWithTerminate() { + val src = createTestFiles().cleanupRecursively() + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + src.copyToRecursively(dst, followLinks = false) { source, target -> + source.copyToIgnoringExistingDirectory(target, followLinks = false) + if (source.name == "3" || source.name == "9.txt") { + CopyActionResult.TERMINATE + } else { + CopyActionResult.CONTINUE + } + } + + // either "3" or "9.txt" is not copied + val copied3 = dst.resolve("1/3").exists() + val copied9 = dst.resolve("8/9.txt").exists() + assertTrue(copied3 || copied9) + assertFalse(copied3 && copied9) + } + + @Test + fun copyFailureWithTerminate() { + val src = createTestFiles().cleanupRecursively() + val dst = createTempDirectory().cleanupRecursively().resolve("dst") + + src.copyToRecursively(dst, followLinks = false, onError = { source, _, exception -> + assertIs(exception) + assertTrue(source.name == "3" || source.name == "9.txt") + OnErrorResult.TERMINATE + }) { source, target -> + source.copyToIgnoringExistingDirectory(target, followLinks = false) + if (source.name == "3" || source.name == "9.txt") throw IllegalArgumentException() + CopyActionResult.CONTINUE + } + + // either "3" or "9.txt" is not copied + val copied3 = dst.resolve("1/3").exists() + val copied9 = dst.resolve("8/9.txt").exists() + assertTrue(copied3 || copied9) + assertFalse(copied3 && copied9) + } + + @Test + fun copyIntoSourceDirectory() { + val source = createTestFiles().cleanupRecursively() + val linkToSource = createTempDirectory().cleanupRecursively().resolve("link").tryCreateSymbolicLinkTo(source) ?: return + val sources = listOf( + source to source, + linkToSource.resolve("8") to source.resolve("8"), + linkToSource.resolve("1/3") to source.resolve("1/3") + ) + + for ((src, resolvedSrc) in sources) { + val linkToSrc = createTempDirectory().cleanupRecursively().resolve("linkToSrc").tryCreateSymbolicLinkTo(resolvedSrc) ?: return + val targets = listOf( + linkToSrc.resolve("a").createDirectory(), + linkToSrc.resolve("a/b").createDirectories() + ) + + for (followLinks in listOf(false, true)) { + assertFailsWith { + src.copyToRecursively(linkToSrc, followLinks = followLinks) + } + for (dst in targets) { + val error = assertFailsWith { + src.copyToRecursively(dst, followLinks = followLinks) + } + assertEquals("Recursively copying a directory into its subdirectory is prohibited.", error.reason) + } + } + } + } + + @Test + fun kt38678() { + val src = createTempDirectory().cleanupRecursively() + src.resolve("test.txt").writeText("plain text file") + + val dst = src.resolve("x") + + val error = assertFailsWith { + src.copyToRecursively(dst, followLinks = false) + } + assertEquals("Recursively copying a directory into its subdirectory is prohibited.", error.reason) + } + + @Test + fun copyToTheSameFile() { + for (src in listOf(createTempFile().cleanupRecursively(), createTestFiles().cleanupRecursively())) { + src.copyToRecursively(src, followLinks = false) + + val link = createTempDirectory().cleanupRecursively().resolve("link").tryCreateSymbolicLinkTo(src) ?: return + + val error = assertFailsWith { + link.copyToRecursively(src, followLinks = false) + } + assertEquals(src.toString(), error.file) + link.copyToRecursively(src, followLinks = true) + + for (followLinks in listOf(false, true)) { + assertFailsWith { + src.copyToRecursively(link, followLinks = followLinks) + } + } + } + } + + @Test + fun copyDstLinkPointingToSrc() { + for (followLinks in listOf(false, true)) { + val root = createTempDirectory().cleanupRecursively() + val src = root.resolve("src").createFile() + val dstLink = root.resolve("dstLink").tryCreateSymbolicLinkTo(src) ?: return + + assertTrue(src.isSameFileAs(dstLink)) + assertTrue(dstLink.isSameFileAs(src)) + assertFailsWith { + src.copyToRecursively(dstLink, followLinks = followLinks) + } + assertTrue(dstLink.isSymbolicLink()) + } + } + + @Test + fun copyDstLinkPointingToSrcOverwrite() { + for (followLinks in listOf(false, true)) { + val root = createTempDirectory().cleanupRecursively() + val src = root.resolve("src").createFile() + val dstLink = root.resolve("dstLink").tryCreateSymbolicLinkTo(src) ?: return + + src.copyToRecursively(dstLink, followLinks = followLinks, overwrite = true) + assertFalse(dstLink.isSymbolicLink()) + } + } + + @Test + fun copySrcLinkAndDstLinkPointingToSameFile() { + for (followLinks in listOf(false, true)) { + val root = createTempDirectory().cleanupRecursively() + val original = root.resolve("original").createFile() + val srcLink = root.resolve("srcLink").tryCreateSymbolicLinkTo(original) ?: return + val dstLink = root.resolve("dstLink").tryCreateSymbolicLinkTo(original) ?: return + + assertTrue(srcLink.isSameFileAs(dstLink)) + assertTrue(dstLink.isSameFileAs(srcLink)) + assertFailsWith { + srcLink.copyToRecursively(dstLink, followLinks = followLinks) + } + assertTrue(dstLink.isSymbolicLink()) + } + } + + @Test + fun copySrcLinkAndDstLinkPointingToSameFileOverwrite() { + for (followLinks in listOf(false, true)) { + val root = createTempDirectory().cleanupRecursively() + val original = root.resolve("original").createFile() + val srcLink = root.resolve("srcLink").tryCreateSymbolicLinkTo(original) ?: return + val dstLink = root.resolve("dstLink").tryCreateSymbolicLinkTo(original) ?: return + + srcLink.copyToRecursively(dstLink, followLinks = followLinks, overwrite = true) + + if (!followLinks) { + assertTrue(dstLink.isSymbolicLink()) // src symlink was copied + } else { + assertFalse(dstLink.isSymbolicLink()) // target of src symlink was copied + } + } + } + + @Test + fun copySameLinkDifferentRoute() { + for (followLinks in listOf(false, true)) { + val root = createTempDirectory().cleanupRecursively() + val original = root.resolve("original").createFile() + val srcLink = root.resolve("srcLink").tryCreateSymbolicLinkTo(original) ?: return + val dstLink = root.resolve("dstLink").tryCreateSymbolicLinkTo(root)?.resolve("srcLink") ?: return + + assertTrue(srcLink.isSameFileAs(dstLink)) + assertTrue(dstLink.isSameFileAs(srcLink)) + + if (!followLinks) { + srcLink.copyToRecursively(dstLink, followLinks = followLinks) // same file + } else { + assertFailsWith { + srcLink.copyToRecursively(dstLink, followLinks = followLinks) // target of srcLink copied to srcLink location + } + } + + assertTrue(dstLink.isSymbolicLink()) + } + } + + @Test + fun copySameLinkDifferentRouteOverwrite() { + for (followLinks in listOf(false, true)) { + val root = createTempDirectory().cleanupRecursively() + val original = root.resolve("original").createFile() + val srcLink = root.resolve("srcLink").tryCreateSymbolicLinkTo(original) ?: return + val dstLink = root.resolve("dstLink").tryCreateSymbolicLinkTo(root)?.resolve("srcLink") ?: return + + if (!followLinks) { + srcLink.copyToRecursively(dstLink, followLinks = followLinks, overwrite = true) // same file + } else { + // dstLink is deleted before srcLink gets copied. + // Actually srcLink gets removed because dstLink is srcLink with different path. + val error = assertFailsWith { + srcLink.copyToRecursively(dstLink, followLinks = followLinks, overwrite = true) + } + assertEquals(srcLink.toString(), error.file) + assertFalse(srcLink.exists(LinkOption.NOFOLLOW_LINKS)) + assertFalse(dstLink.exists(LinkOption.NOFOLLOW_LINKS)) + } + } + } + + @Test + fun copySameFileDifferentRoute() { + val root = createTempDirectory().cleanupRecursively() + val src = root.resolve("src").createFile() + val dst = root.resolve("dstLink").tryCreateSymbolicLinkTo(root)?.resolve("src") ?: return + + assertTrue(src.isSameFileAs(dst)) + assertTrue(dst.isSameFileAs(src)) + src.copyToRecursively(dst, followLinks = false) + } + + @Test + fun copyToSameFileDifferentRouteOverwrite() { + val root = createTempDirectory().cleanupRecursively() + val src = root.resolve("src").createFile() + val dst = root.resolve("dstLink").tryCreateSymbolicLinkTo(root)?.resolve("src") ?: return + + src.copyToRecursively(dst, followLinks = false, overwrite = true) + } +} diff --git a/libraries/tools/binary-compatibility-validator/reference-public-api/kotlin-stdlib-runtime-merged.txt b/libraries/tools/binary-compatibility-validator/reference-public-api/kotlin-stdlib-runtime-merged.txt index 438a58f990f..d8a6e1da5fd 100644 --- a/libraries/tools/binary-compatibility-validator/reference-public-api/kotlin-stdlib-runtime-merged.txt +++ b/libraries/tools/binary-compatibility-validator/reference-public-api/kotlin-stdlib-runtime-merged.txt @@ -3267,6 +3267,18 @@ public final class kotlin/io/TextStreamsKt { public static final fun useLines (Ljava/io/Reader;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; } +public abstract interface class kotlin/io/path/CopyActionContext { + public abstract fun copyToIgnoringExistingDirectory (Ljava/nio/file/Path;Ljava/nio/file/Path;Z)Lkotlin/io/path/CopyActionResult; +} + +public final class kotlin/io/path/CopyActionResult : java/lang/Enum { + public static final field CONTINUE Lkotlin/io/path/CopyActionResult; + public static final field SKIP_SUBTREE Lkotlin/io/path/CopyActionResult; + public static final field TERMINATE Lkotlin/io/path/CopyActionResult; + public static fun valueOf (Ljava/lang/String;)Lkotlin/io/path/CopyActionResult; + public static fun values ()[Lkotlin/io/path/CopyActionResult; +} + public abstract interface annotation class kotlin/io/path/ExperimentalPathApi : java/lang/annotation/Annotation { } @@ -3277,6 +3289,13 @@ public abstract interface class kotlin/io/path/FileVisitorBuilder { public abstract fun onVisitFileFailed (Lkotlin/jvm/functions/Function2;)V } +public final class kotlin/io/path/OnErrorResult : java/lang/Enum { + public static final field SKIP_SUBTREE Lkotlin/io/path/OnErrorResult; + public static final field TERMINATE Lkotlin/io/path/OnErrorResult; + public static fun valueOf (Ljava/lang/String;)Lkotlin/io/path/OnErrorResult; + public static fun values ()[Lkotlin/io/path/OnErrorResult; +} + public final class kotlin/io/path/PathWalkOption : java/lang/Enum { public static final field BREADTH_FIRST Lkotlin/io/path/PathWalkOption; public static final field FOLLOW_LINKS Lkotlin/io/path/PathWalkOption; @@ -3288,10 +3307,15 @@ public final class kotlin/io/path/PathWalkOption : java/lang/Enum { public final class kotlin/io/path/PathsKt { public static final fun appendText (Ljava/nio/file/Path;Ljava/lang/CharSequence;Ljava/nio/charset/Charset;)V public static synthetic fun appendText$default (Ljava/nio/file/Path;Ljava/lang/CharSequence;Ljava/nio/charset/Charset;ILjava/lang/Object;)V + public static final fun copyToRecursively (Ljava/nio/file/Path;Ljava/nio/file/Path;Lkotlin/jvm/functions/Function3;ZLkotlin/jvm/functions/Function3;)Ljava/nio/file/Path; + public static final fun copyToRecursively (Ljava/nio/file/Path;Ljava/nio/file/Path;Lkotlin/jvm/functions/Function3;ZZ)Ljava/nio/file/Path; + public static synthetic fun copyToRecursively$default (Ljava/nio/file/Path;Ljava/nio/file/Path;Lkotlin/jvm/functions/Function3;ZLkotlin/jvm/functions/Function3;ILjava/lang/Object;)Ljava/nio/file/Path; + public static synthetic fun copyToRecursively$default (Ljava/nio/file/Path;Ljava/nio/file/Path;Lkotlin/jvm/functions/Function3;ZZILjava/lang/Object;)Ljava/nio/file/Path; public static final fun createTempDirectory (Ljava/nio/file/Path;Ljava/lang/String;[Ljava/nio/file/attribute/FileAttribute;)Ljava/nio/file/Path; public static synthetic fun createTempDirectory$default (Ljava/nio/file/Path;Ljava/lang/String;[Ljava/nio/file/attribute/FileAttribute;ILjava/lang/Object;)Ljava/nio/file/Path; public static final fun createTempFile (Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;[Ljava/nio/file/attribute/FileAttribute;)Ljava/nio/file/Path; public static synthetic fun createTempFile$default (Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;[Ljava/nio/file/attribute/FileAttribute;ILjava/lang/Object;)Ljava/nio/file/Path; + public static final fun deleteRecursively (Ljava/nio/file/Path;)V public static final fun fileAttributeViewNotAvailable (Ljava/nio/file/Path;Ljava/lang/Class;)Ljava/lang/Void; public static final fun fileVisitor (Lkotlin/jvm/functions/Function1;)Ljava/nio/file/FileVisitor; public static final fun getExtension (Ljava/nio/file/Path;)Ljava/lang/String;