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()