From 121b93a3dae33f7e2cb7260338b1e6826ad6c1dc Mon Sep 17 00:00:00 2001 From: "anggrayudi.hardiannico" Date: Sun, 16 Aug 2026 03:49:42 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20recursive=20delete=20for=20no?= =?UTF-8?q?n-empty=20directories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delete() gains a `recursively` flag, defaulting to false, so existing calls behave exactly as before and a non-empty directory still fails. Symlinks are unlinked rather than followed. kotlinx-io's FileMetadata carries no link information and isDirectory() resolves the link, so a naive recursion would delete the contents of whatever the link points at. A new internal isSymbolicLink() answers that per platform: Files.isSymbolicLink on JVM, Os.lstat on Android (java.nio needs API 26 and this library supports 21), attributesOfItemAtPath on Apple, lstat on Linux, and the reparse point attribute on Windows. The SAF branch on Android is left alone: removing a document is the provider's job and there is no empty-directory rule to work around. Closes #640 Co-Authored-By: Claude Opus 5 (1M context) --- .../vinceglb/filekit/PlatformFile.android.kt | 18 +++++++- .../vinceglb/filekit/PlatformFile.apple.kt | 12 +++++ .../filekit/PlatformFile.jvmAndNative.kt | 3 +- .../vinceglb/filekit/PlatformFile.jvm.kt | 3 ++ .../vinceglb/filekit/PlatformFileJvmTest.kt | 25 ++++++++++ .../vinceglb/filekit/PlatformFile.linux.kt | 11 +++++ .../vinceglb/filekit/PlatformFile.mingw.kt | 12 +++++ .../vinceglb/filekit/PlatformFile.nonWeb.kt | 28 ++++++++++- .../filekit/PlatformFileNonWebTest.kt | 46 +++++++++++++++++++ 9 files changed, 155 insertions(+), 3 deletions(-) diff --git a/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt index 4b865392..8de9794d 100644 --- a/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt +++ b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt @@ -8,6 +8,8 @@ import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.MediaStore import android.provider.OpenableColumns +import android.system.Os +import android.system.OsConstants import android.webkit.MimeTypeMap import androidx.documentfile.provider.DocumentFile import io.github.vinceglb.filekit.exceptions.FileKitException @@ -449,16 +451,19 @@ private fun PlatformFile.resolveAtomicMoveDestination(source: PlatformFile): Pla return this } -public actual suspend fun PlatformFile.delete(mustExist: Boolean): Unit = +public actual suspend fun PlatformFile.delete(mustExist: Boolean, recursively: Boolean): Unit = withContext(Dispatchers.IO) { when (androidFile) { is AndroidFile.FileWrapper -> { + if (recursively) deleteChildren() SystemFileSystem.delete( path = toKotlinxIoPath(), mustExist = mustExist, ) } + // No recursion here: SAF has no empty-directory rule to work around. Removing a + // document is the provider's job, and DocumentsContract takes the subtree with it. is AndroidFile.UriWrapper -> { val documentFile = DocumentFile.fromSingleUri(FileKit.context, androidFile.uri) ?: throw FileKitException("Could not access Uri as DocumentFile") @@ -1143,3 +1148,14 @@ private fun Uri.toFileOrNull(): File? { val filePath = path ?: return null return File(filePath) } + +// Os.lstat rather than java.nio.file.Files.isSymbolicLink: the latter needs API 26, and this +// library still supports 21. lstat reports on the entry itself, where stat would follow the link. +internal actual fun PlatformFile.isSymbolicLink(): Boolean = when (val file = androidFile) { + is AndroidFile.FileWrapper -> runCatching { + OsConstants.S_ISLNK(Os.lstat(file.file.absolutePath).st_mode) + }.getOrDefault(false) + + // A document provider exposes no link concept; an entry is whatever it says it is. + is AndroidFile.UriWrapper -> false +} diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt index 75a98c98..c2db4311 100644 --- a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt +++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt @@ -39,6 +39,9 @@ import platform.CoreServices.UTTypeCopyPreferredTagWithClass import platform.CoreServices.kUTTagClassMIMEType import platform.Foundation.NSDate import platform.Foundation.NSError +import platform.Foundation.NSFileManager +import platform.Foundation.NSFileType +import platform.Foundation.NSFileTypeSymbolicLink import platform.Foundation.NSLock import platform.Foundation.NSURL import platform.Foundation.NSURLContentModificationDateKey @@ -424,3 +427,12 @@ private fun NSError?.toBookmarkResolutionException(): BookmarkResolutionExceptio reason = classifyAppleBookmarkResolutionError(this), message = "Failed to resolve bookmark data: $this", ) + +// attributesOfItemAtPath does not resolve the link, so a symlink reports its own type here rather +// than the type of whatever it points at. +@OptIn(ExperimentalForeignApi::class) +internal actual fun PlatformFile.isSymbolicLink(): Boolean { + val path = nsUrl.path ?: return false + val attributes = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null) + return attributes?.get(NSFileType) == NSFileTypeSymbolicLink +} diff --git a/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt b/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt index 0da7dd8f..9f66e08b 100644 --- a/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt +++ b/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt @@ -79,9 +79,10 @@ public actual fun PlatformFile.createDirectories(mustCreate: Boolean): Unit = SystemFileSystem.createDirectories(toKotlinxIoPath(), mustCreate) } -public actual suspend fun PlatformFile.delete(mustExist: Boolean): Unit = +public actual suspend fun PlatformFile.delete(mustExist: Boolean, recursively: Boolean): Unit = withScopedAccess { withContext(Dispatchers.IO) { + if (recursively) deleteChildren() SystemFileSystem.delete(path = toKotlinxIoPath(), mustExist = mustExist) } } diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt index d2938987..897f4eeb 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt @@ -180,3 +180,6 @@ public actual fun PlatformFile.Companion.resolveBookmarkData( shouldRefresh = Platform.isMac(), ) } + +internal actual fun PlatformFile.isSymbolicLink(): Boolean = + Files.isSymbolicLink(file.toPath()) diff --git a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt index d719d23e..cadb68a2 100644 --- a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt +++ b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt @@ -10,14 +10,39 @@ import io.github.vinceglb.filekit.mimeType.MimeType import kotlinx.coroutines.test.runTest import kotlinx.io.files.Path import java.io.File +import java.nio.file.Files import kotlin.coroutines.Continuation +import kotlin.io.path.createDirectory import kotlin.io.path.createTempDirectory +import kotlin.io.path.exists +import kotlin.io.path.writeText import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertTrue class PlatformFileJvmTest { + @Test + fun PlatformFile_deleteRecursively_unlinksSymlinkAndLeavesItsTargetIntact() = runTest { + val tempRoot = createTempDirectory("filekit-symlink-test") + try { + val outside = tempRoot.resolve("outside").createDirectory() + val treasure = outside.resolve("treasure.txt") + treasure.writeText("must survive") + + val doomed = tempRoot.resolve("doomed").createDirectory() + Files.createSymbolicLink(doomed.resolve("link-to-outside"), outside) + + PlatformFile(doomed.toFile()).delete(recursively = true) + + assertFalse(doomed.exists(), "the tree the caller asked to remove is gone") + assertTrue(treasure.exists(), "the symlink target lives outside that tree and is untouched") + } finally { + tempRoot.toFile().deleteRecursively() + } + } + private val resourceDirectory = PlatformFile(Path("src/nonWebTest/resources")) private val textFile = PlatformFile(resourceDirectory, "hello.txt") private val imageFile = PlatformFile(resourceDirectory, "compose-logo.png") diff --git a/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/PlatformFile.linux.kt b/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/PlatformFile.linux.kt index 99e91cd9..9447ab8e 100644 --- a/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/PlatformFile.linux.kt +++ b/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/PlatformFile.linux.kt @@ -17,8 +17,11 @@ import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem import kotlinx.io.readString import kotlinx.serialization.Serializable +import platform.posix.S_IFLNK +import platform.posix.S_IFMT import platform.posix.fnmatch import platform.posix.getcwd +import platform.posix.lstat import platform.posix.stat import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -295,3 +298,11 @@ public actual fun PlatformFile.Companion.resolveBookmarkData( shouldRefresh = false, ) } + +// lstat, not stat: stat would resolve the link and report on its target. +@OptIn(ExperimentalForeignApi::class) +internal actual fun PlatformFile.isSymbolicLink(): Boolean = memScoped { + val statBuf = alloc() + if (lstat(absolutePath(), statBuf.ptr) != 0) return@memScoped false + (statBuf.st_mode.toInt() and S_IFMT) == S_IFLNK +} diff --git a/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt b/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt index fc0fad6c..38a41cd0 100644 --- a/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt +++ b/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt @@ -19,8 +19,11 @@ import kotlinx.io.files.SystemFileSystem import kotlinx.serialization.Serializable import platform.windows.DWORDVar import platform.windows.FILETIME +import platform.windows.FILE_ATTRIBUTE_REPARSE_POINT import platform.windows.GET_FILEEX_INFO_LEVELS import platform.windows.GetFileAttributesExW +import platform.windows.GetFileAttributesW +import platform.windows.INVALID_FILE_ATTRIBUTES import platform.windows.GetFullPathNameW import platform.windows.HKEYVar import platform.windows.HKEY_CLASSES_ROOT @@ -230,3 +233,12 @@ private fun FILETIME.toInstant(): Instant { val epochMillis = (windowsTicks / 10_000L) - 11_644_473_600_000L return Instant.fromEpochMilliseconds(epochMillis) } + +// Windows models symlinks and junctions as reparse points, and GetFileAttributesW reports on the +// entry itself rather than on whatever it redirects to. +@OptIn(ExperimentalForeignApi::class) +internal actual fun PlatformFile.isSymbolicLink(): Boolean { + val attributes = GetFileAttributesW(absolutePath()) + if (attributes == INVALID_FILE_ATTRIBUTES) return false + return (attributes and FILE_ATTRIBUTE_REPARSE_POINT.toUInt()) != 0u +} diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt index e1e6c115..364f54c9 100644 --- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt @@ -216,8 +216,34 @@ public expect suspend fun PlatformFile.atomicMove(destination: PlatformFile) * Deletes this file. * * @param mustExist If `true`, fails if the file does not exist. Defaults to `true`. + * @param recursively If `true`, a directory is emptied before it is removed. Defaults to `false`, + * which fails on a directory that still has contents. Symbolic links are unlinked, never followed. */ -public expect suspend fun PlatformFile.delete(mustExist: Boolean = true) +public expect suspend fun PlatformFile.delete( + mustExist: Boolean = true, + recursively: Boolean = false, +) + +/** + * Empties this directory, depth first, leaving the directory itself in place. Does nothing when + * this is not a directory. + * + * A symbolic link is removed as a link and never descended into: following one would delete the + * contents of whatever it points at, which lives outside the tree the caller asked to remove. + */ +internal suspend fun PlatformFile.deleteChildren() { + if (!isDirectory() || isSymbolicLink()) return + list().forEach { child -> + child.deleteChildren() + child.delete(mustExist = false) + } +} + +/** + * Whether this path is a symbolic link, asked without resolving it. [isDirectory] cannot answer + * this: it follows the link and reports on the target. + */ +internal expect fun PlatformFile.isSymbolicLink(): Boolean /** * Appends a child path to this [PlatformFile]. diff --git a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt index da9c2654..01626053 100644 --- a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt +++ b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/PlatformFileNonWebTest.kt @@ -109,6 +109,52 @@ class PlatformFileNonWebTest { PlatformFile("").delete(mustExist = false) } + @Test + fun PlatformFile_deleteNonEmptyDirectory_fails() = runTest { + val root = resourceDirectory / "delete-non-recursive" + try { + (root / "nested").createDirectories() + (root / "nested" / "leaf.txt").writeString("leaf") + + assertFailsWith { root.delete() } + assertTrue(root.exists()) + } finally { + root.delete(mustExist = false, recursively = true) + } + } + + @Test + fun PlatformFile_deleteNonEmptyDirectoryRecursively_removesWholeTree() = runTest { + val root = resourceDirectory / "delete-recursive" + try { + (root / "a" / "b").createDirectories() + (root / "a" / "b" / "leaf.txt").writeString("leaf") + (root / "a" / "sibling.txt").writeString("sibling") + (root / "top.txt").writeString("top") + + root.delete(recursively = true) + + assertFalse(root.exists()) + } finally { + root.delete(mustExist = false, recursively = true) + } + } + + @Test + fun PlatformFile_deleteFileRecursively_removesFile() = runTest { + val file = resourceDirectory / "delete-recursive-file.txt" + file.writeString("content") + + file.delete(recursively = true) + + assertFalse(file.exists()) + } + + @Test + fun PlatformFile_deleteMissingPathRecursivelyWithMustExistFalse_doesNothing() = runTest { + (resourceDirectory / "never-created-directory").delete(mustExist = false, recursively = true) + } + @Test fun testPlatformFileReadBytes() = runTest { val textFileContent = textFile.readString()