Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,6 @@ public actual fun PlatformFile.Companion.resolveBookmarkData(
shouldRefresh = Platform.isMac(),
)
}

internal actual fun PlatformFile.isSymbolicLink(): Boolean =
Files.isSymbolicLink(file.toPath())
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<stat>()
if (lstat(absolutePath(), statBuf.ptr) != 0) return@memScoped false
(statBuf.st_mode.toInt() and S_IFMT) == S_IFLNK
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IOException> { 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()
Expand Down