diff --git a/build-logic/src/main/kotlin/kotlinx/io/conventions/kotlinx-io-multiplatform.gradle.kts b/build-logic/src/main/kotlin/kotlinx/io/conventions/kotlinx-io-multiplatform.gradle.kts index 05b978353..2e26a7749 100644 --- a/build-logic/src/main/kotlin/kotlinx/io/conventions/kotlinx-io-multiplatform.gradle.kts +++ b/build-logic/src/main/kotlin/kotlinx/io/conventions/kotlinx-io-multiplatform.gradle.kts @@ -97,12 +97,14 @@ kotlin { group("androidNative") } } - - group("nativeNonAndroid") { + group("appleAndLinux") { group("apple") - group("mingw") group("linux") } + group("posix") { + group("apple") + group("unix") + } } group("nodeFilesystemShared") { withJs() diff --git a/core/nativeNonAndroid/src/files/FileSystemNativeNonAndroid.kt b/core/appleAndLinux/src/files/FileSystemAppleAndLinux.kt similarity index 95% rename from core/nativeNonAndroid/src/files/FileSystemNativeNonAndroid.kt rename to core/appleAndLinux/src/files/FileSystemAppleAndLinux.kt index a75172231..86e051ae8 100644 --- a/core/nativeNonAndroid/src/files/FileSystemNativeNonAndroid.kt +++ b/core/appleAndLinux/src/files/FileSystemAppleAndLinux.kt @@ -13,8 +13,10 @@ import kotlinx.io.IOException import platform.posix.DIR import platform.posix.closedir import platform.posix.errno +import platform.posix.opendir import platform.posix.strerror + @OptIn(ExperimentalForeignApi::class) internal actual class OpaqueDirEntry(private val dir: CPointer) : AutoCloseable { actual fun readdir(): String? { @@ -33,7 +35,7 @@ internal actual class OpaqueDirEntry(private val dir: CPointer) : AutoClose @OptIn(ExperimentalForeignApi::class) internal actual fun opendir(path: String): OpaqueDirEntry { - val dirent = platform.posix.opendir(path) + val dirent = opendir(path) if (dirent != null) return OpaqueDirEntry(dirent) val err = errno diff --git a/core/common/test/files/SmokeFileTestWindows.kt b/core/common/test/files/SmokeFileTestWindows.kt index 23994807e..ecf7096a5 100644 --- a/core/common/test/files/SmokeFileTestWindows.kt +++ b/core/common/test/files/SmokeFileTestWindows.kt @@ -11,13 +11,20 @@ class SmokeFileTestWindows { @Test fun isAbsolute() { if (!isWindows) return - assertFalse(Path("C:").isAbsolute) - assertTrue(Path("C:\\").isAbsolute) - assertTrue(Path("C:/").isAbsolute) - assertTrue(Path("C:/../").isAbsolute) - assertFalse(Path("C:file").isAbsolute) - assertFalse(Path("bla\\bla\\bla").isAbsolute) - assertTrue(Path("\\\\server\\share").isAbsolute) + fun assertIsAbsolute(path: String) { + assertTrue(Path(path).isAbsolute, "Expected absolute path: $path") + } + fun assertIsRelative(path: String) { + assertFalse(Path(path).isAbsolute, "Expected relative path: $path") + } + assertIsRelative("C:") + assertIsAbsolute("C:\\") + assertIsAbsolute("C:/") + assertIsAbsolute("C:/../") + assertIsRelative("C:file") + assertIsRelative("bla\\bla\\bla") + assertIsAbsolute("\\\\server\\share") + assertIsAbsolute("\\\\?\\C:\\Test\\Foo.txt") } @Test diff --git a/core/mingw/src/files/Error.kt b/core/mingw/src/files/Error.kt new file mode 100644 index 000000000..459563b13 --- /dev/null +++ b/core/mingw/src/files/Error.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2010-2025 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.txt file. + */ +package kotlinx.io.files + +import kotlinx.cinterop.* +import platform.windows.* + +@OptIn(ExperimentalForeignApi::class) +internal fun formatWin32ErrorMessage(code: UInt = GetLastError()): String { + memScoped { + val r = alloc() + val n = FormatMessageW( + dwFlags = (FORMAT_MESSAGE_ALLOCATE_BUFFER or FORMAT_MESSAGE_IGNORE_INSERTS or FORMAT_MESSAGE_FROM_SYSTEM).toUInt(), + lpSource = null, + dwMessageId = code, + dwLanguageId = 0u, + lpBuffer = r.ptr.reinterpret(), + nSize = 0u, + Arguments = null, + ) + if (n == 0u) { + return "unknown error (${code.toHexString()})" + } + val s = r.value!!.toKString().trimEnd() + LocalFree(r.value) + return "$s (${code.toHexString()})" + } + +} diff --git a/core/mingw/src/files/FileSystemMingw.kt b/core/mingw/src/files/FileSystemMingw.kt index e7ad2cc8a..525c6f057 100644 --- a/core/mingw/src/files/FileSystemMingw.kt +++ b/core/mingw/src/files/FileSystemMingw.kt @@ -9,54 +9,134 @@ package kotlinx.io.files import kotlinx.cinterop.* import kotlinx.io.IOException -import platform.posix.* import platform.windows.* + internal actual fun atomicMoveImpl(source: Path, destination: Path) { - if (MoveFileExA(source.path, destination.path, MOVEFILE_REPLACE_EXISTING.convert()) == 0) { - // TODO: get formatted error message - throw IOException("Move failed with error code: ${GetLastError()}") + if (MoveFileExW(source.path, destination.path, MOVEFILE_REPLACE_EXISTING.convert()) == 0) { + throw IOException("Move failed with error code: ${formatWin32ErrorMessage()}") } } internal actual fun dirnameImpl(path: String): String { - if (!path.contains(UnixPathSeparator) && !path.contains(WindowsPathSeparator)) { - return "" - } + val path = path.replace(UnixPathSeparator, WindowsPathSeparator) memScoped { - return dirname(path.cstr.ptr)?.toKString() ?: "" + val p = path.wcstr.ptr + // This function is deprecated, should use PathCchRemoveFileSpec, + // but it's not available in current version of Kotlin + PathRemoveFileSpecW(p) + return p.toKString() } } internal actual fun basenameImpl(path: String): String { - memScoped { - return basename(path.cstr.ptr)?.toKString() ?: "" - } + if (PathIsRootW(path) == TRUE) return "" + return PathFindFileNameW(path)?.toKString() ?: "" } internal actual fun isAbsoluteImpl(path: String): Boolean { - if (path.startsWith(SystemPathSeparator)) return true - if (path.length > 1 && path[1] == ':') { - if (path.length == 2) return false - val next = path[2] - return next == WindowsPathSeparator || next == SystemPathSeparator + val p = path.replace(UnixPathSeparator, WindowsPathSeparator) + if (PathIsRelativeW(p) == TRUE) { + return false + } + // PathIsRelativeW returns FALSE for paths like "C:relative\path" which are not absolute, in DoS + if (p.length >= 2 && p[0].isLetter() && p[1] == ':') { + return p.length > 2 && (p[2] == WindowsPathSeparator || p[2] == UnixPathSeparator) } - return PathIsRelativeA(path) == 0 + return true } internal actual fun mkdirImpl(path: String) { - if (mkdir(path) != 0) { - throw IOException("mkdir failed: ${strerror(errno)?.toKString()}") + if (CreateDirectoryW(path, null) == FALSE) { + throw IOException("mkdir failed: $path: ${formatWin32ErrorMessage()}") } } -private const val MAX_PATH_LENGTH = 32767 - internal actual fun realpathImpl(path: String): String { memScoped { - val buffer = allocArray(MAX_PATH_LENGTH) - val len = GetFullPathNameA(path, MAX_PATH_LENGTH.convert(), buffer, null) - if (len == 0u) throw IllegalStateException() - return buffer.toKString() + // in practice, MAX_PATH is enough for most cases + var buf = allocArray(MAX_PATH) + var r = GetFullPathNameW(path, MAX_PATH.convert(), buf, null) + if (r >= MAX_PATH.toUInt()) { + // if not, we will retry with the required size + buf = allocArray(r.toInt()) + r = GetFullPathNameW(path, r, buf, null) + } + if (r == 0u) { + error("GetFullPathNameW failed for $path: ${formatWin32ErrorMessage()}") + } + return buf.toKString() + } +} + +internal actual class OpaqueDirEntry(private val directory: String) : AutoCloseable { + private val arena = Arena() + private val data = arena.alloc() + private var handle: HANDLE? = INVALID_HANDLE_VALUE + private var firstName: String? = null + + init { + try { + // since the root + val directory0 = + if (directory.endsWith(UnixPathSeparator) || directory.endsWith(WindowsPathSeparator)) "$directory*" else "$directory/*" + handle = FindFirstFileW(directory0, data.ptr) + if (handle != INVALID_HANDLE_VALUE) { + firstName = data.cFileName.toKString() + } else { + val e = GetLastError() + if (e != ERROR_FILE_NOT_FOUND.toUInt()) { + throw IOException("Can't open directory $directory: ${formatWin32ErrorMessage(e)}") + } + } + } catch (th: Throwable) { + if (handle != INVALID_HANDLE_VALUE) { + CloseHandle(handle) + } + arena.clear() + throw th + } + } + + actual fun readdir(): String? { + if (firstName != null) { + return firstName.also { firstName = null } + } + if (handle == INVALID_HANDLE_VALUE) { + return null + } + if (FindNextFileW(handle, data.ptr) == TRUE) { + return data.cFileName.toKString() + } + val le = GetLastError() + if (le == ERROR_NO_MORE_FILES.toUInt()) { + return null + } + throw IOException("Can't readdir from $directory: ${formatWin32ErrorMessage(le)}") + } + + actual override fun close() { + if (handle != INVALID_HANDLE_VALUE) { + FindClose(handle) + } + arena.clear() + } + +} + +internal actual fun opendir(path: String): OpaqueDirEntry = OpaqueDirEntry(path) + +internal actual fun existsImpl(path: String): Boolean = PathFileExistsW(path) == TRUE + +internal actual fun deleteNoCheckImpl(path: String) { + if (DeleteFileW(path) != FALSE) return + var e = GetLastError() + if (e == ERROR_FILE_NOT_FOUND.toUInt()) return // ignore it + if (e == ERROR_ACCESS_DENIED.toUInt()) { + // might be a directory + if (RemoveDirectoryW(path) != FALSE) return + e = GetLastError() + if (e == ERROR_FILE_NOT_FOUND.toUInt()) return // ignore it } + throw IOException("Delete failed for $path: ${formatWin32ErrorMessage(e)}") } diff --git a/core/mingw/src/files/PathsMingw.kt b/core/mingw/src/files/PathsMingw.kt new file mode 100644 index 000000000..8329c1d1b --- /dev/null +++ b/core/mingw/src/files/PathsMingw.kt @@ -0,0 +1,7 @@ +/* + * Copyright 2010-2025 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.txt file. + */ +package kotlinx.io.files + +public actual val SystemPathSeparator: Char get() = WindowsPathSeparator diff --git a/core/mingw/test/files/SmokeFileTestWindowsMinGW.kt b/core/mingw/test/files/SmokeFileTestWindowsMinGW.kt index 2dcf04021..38a5d48c0 100644 --- a/core/mingw/test/files/SmokeFileTestWindowsMinGW.kt +++ b/core/mingw/test/files/SmokeFileTestWindowsMinGW.kt @@ -5,13 +5,83 @@ package kotlinx.io.files +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.cstr +import kotlinx.cinterop.toKString +import platform.posix.dirname +import platform.windows.ERROR_TOO_MANY_OPEN_FILES +import platform.windows.GetConsoleCP import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertTrue + +@OptIn(ExperimentalForeignApi::class) +class SmokeFileTestWindowsMinGW { + private val testDir = Path("""./mingw/testdir""") + + @OptIn(ExperimentalForeignApi::class) + @Test + fun mingwProblem() { + // Skipping test because console code page is UTF-8, + // use when clause because I'm not sure which codepage should be skipped + when (GetConsoleCP()) { + 65001u -> return + } + assertEquals("""C:\foo""", dirname("""C:\foo\bar""".cstr)!!.toKString()) + assertFails { + assertEquals( + """C:\あいうえお""", + dirname("""C:\あいうえお\かきくけこ""".cstr)!!.toKString(), + ) + } + assertFails { + assertEquals( + """C:\一二三四""", + dirname("""C:\一二三四\五六七八""".cstr)!!.toKString(), + ) + } + } + + @Test + fun parent() { + assertEquals(Path("""C:\foo"""), Path("""C:\foo\bar""").parent) + assertEquals(Path("""C:\あいうえお"""), Path("""C:\あいうえお\かきくけこ""").parent) + assertEquals(Path("""C:\一二三四"""), Path("""C:\一二三四\五六七八""").parent) + assertEquals(null, Path("""C:\""").parent) + } -class SmokeFileTestWindowsMinGW { @Test fun uncParent() { - assertEquals(Path("\\\\server"), Path("\\\\server\\share").parent) - assertEquals(Path("\\\\server\\share"), Path("\\\\server\\share\\dir").parent) + assertEquals(Path("""\\server\share"""), Path("""\\server\share\dir""").parent) + // This is a root UNC path, so parent is + assertEquals(null, Path("""\\server\share""").parent) + } + + @Test + fun basename() { + assertEquals("あいうえお", Path("""C:\あいうえお""").name) + assertEquals("", Path("""C:\""").name) + } + + @Test + fun testFormatError() { + val s = formatWin32ErrorMessage(ERROR_TOO_MANY_OPEN_FILES.toUInt()) + // it should be trimmed, drop the trailing rubbish + assertEquals(s.trim(), s) + } + + @Test + fun testReadDir() { + val expected = listOf("foo", "いろは歌", "天地玄黄") + val actual = SystemFileSystem.list(testDir).map { it.name }.sorted() + assertEquals(expected, actual) + } + + @Test + fun testExists() { + for (path in SystemFileSystem.list(testDir)) { + assertTrue(SystemFileSystem.exists(path), path.toString()) + } } } diff --git a/core/mingw/testdir/foo b/core/mingw/testdir/foo new file mode 100644 index 000000000..e69de29bb diff --git "a/core/mingw/testdir/\343\201\204\343\202\215\343\201\257\346\255\214" "b/core/mingw/testdir/\343\201\204\343\202\215\343\201\257\346\255\214" new file mode 100644 index 000000000..e69de29bb diff --git "a/core/mingw/testdir/\345\244\251\345\234\260\347\216\204\351\273\204" "b/core/mingw/testdir/\345\244\251\345\234\260\347\216\204\351\273\204" new file mode 100644 index 000000000..e69de29bb diff --git a/core/native/src/files/FileSystemNative.kt b/core/native/src/files/FileSystemNative.kt index 1fa719b15..9859d7efa 100644 --- a/core/native/src/files/FileSystemNative.kt +++ b/core/native/src/files/FileSystemNative.kt @@ -16,9 +16,7 @@ import kotlin.experimental.ExperimentalNativeApi @OptIn(ExperimentalForeignApi::class) public actual val SystemFileSystem: FileSystem = object : SystemFileSystemImpl() { - override fun exists(path: Path): Boolean { - return access(path.path, F_OK) == 0 - } + override fun exists(path: Path): Boolean = existsImpl(path.path) @OptIn(ExperimentalForeignApi::class) override fun delete(path: Path, mustExist: Boolean) { @@ -28,12 +26,7 @@ public actual val SystemFileSystem: FileSystem = object : SystemFileSystemImpl() } return } - if (remove(path.path) != 0) { - if (errno == EACCES) { - if (rmdir(path.path) == 0) return - } - throw IOException("Delete failed for $path: ${strerror(errno)?.toKString()}") - } + deleteNoCheckImpl(path.path) } override fun createDirectories(path: Path, mustCreate: Boolean) { @@ -114,6 +107,10 @@ internal expect fun mkdirImpl(path: String) internal expect fun realpathImpl(path: String): String +internal expect fun existsImpl(path: String): Boolean + +internal expect fun deleteNoCheckImpl(path: String) + public actual open class FileNotFoundException actual constructor( message: String? ) : IOException(message) diff --git a/core/native/src/files/PathsNative.kt b/core/native/src/files/PathsNative.kt index e013afd60..85ed8bbc4 100644 --- a/core/native/src/files/PathsNative.kt +++ b/core/native/src/files/PathsNative.kt @@ -54,7 +54,6 @@ public actual class Path internal constructor( } } -public actual val SystemPathSeparator: Char get() = UnixPathSeparator internal expect fun dirnameImpl(path: String): String diff --git a/core/posix/src/files/FileSystemNativePosix.kt b/core/posix/src/files/FileSystemNativePosix.kt new file mode 100644 index 000000000..80658473e --- /dev/null +++ b/core/posix/src/files/FileSystemNativePosix.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2010-2025 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.txt file. + */ +package kotlinx.io.files + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import kotlinx.io.IOException +import platform.posix.* + +internal actual fun existsImpl(path: String): Boolean = access(path, F_OK) == 0 + +@OptIn(ExperimentalForeignApi::class) +internal actual fun deleteNoCheckImpl(path: String) { + if (remove(path) != 0) { + if (errno == EACCES) { + if (rmdir(path) == 0) return + } + throw IOException("Delete failed for $path: ${strerror(errno)?.toKString()}") + } +} diff --git a/core/posix/src/files/PathsPosix.kt b/core/posix/src/files/PathsPosix.kt new file mode 100644 index 000000000..784c00e47 --- /dev/null +++ b/core/posix/src/files/PathsPosix.kt @@ -0,0 +1,7 @@ +/* + * Copyright 2010-2025 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.txt file. + */ +package kotlinx.io.files + +public actual val SystemPathSeparator: Char get() = UnixPathSeparator