diff --git a/okio-fakefilesystem/api/okio-fakefilesystem.api b/okio-fakefilesystem/api/okio-fakefilesystem.api index 0d9c5e3803..dedcce5073 100644 --- a/okio-fakefilesystem/api/okio-fakefilesystem.api +++ b/okio-fakefilesystem/api/okio-fakefilesystem.api @@ -25,6 +25,7 @@ public final class okio/fakefilesystem/FakeFileSystem : okio/FileSystem { public final fun getWorkingDirectory ()Lokio/Path; public fun list (Lokio/Path;)Ljava/util/List; public fun listOrNull (Lokio/Path;)Ljava/util/List; + public fun lock (Lokio/Path;Lokio/LockMode;)Lokio/FileLock; public fun metadataOrNull (Lokio/Path;)Lokio/FileMetadata; public final fun openPaths ()Ljava/util/List; public fun openReadOnly (Lokio/Path;)Lokio/FileHandle; @@ -42,3 +43,8 @@ public final class okio/fakefilesystem/FakeFileSystem : okio/FileSystem { public fun toString ()Ljava/lang/String; } +public final class okio/fakefilesystem/FakeFileSystemLock { + public fun (Lokio/Path;)V + public final fun lock (Lokio/LockMode;)Lokio/FileLock; +} + diff --git a/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt b/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt index 5ffddd4844..22a827bffc 100644 --- a/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt +++ b/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt @@ -23,10 +23,12 @@ import okio.ArrayIndexOutOfBoundsException import okio.Buffer import okio.ByteString import okio.FileHandle +import okio.FileLock import okio.FileMetadata import okio.FileNotFoundException import okio.FileSystem import okio.IOException +import okio.LockMode import okio.Path import okio.Path.Companion.toPath import okio.Sink @@ -103,6 +105,9 @@ class FakeFileSystem private constructor( /** Files that are currently open and need to be closed to avoid resource leaks. */ private val openFiles = mutableListOf() + /** Lock coordination points. */ + private val locks = mutableMapOf() + /** Forbid all access after [close]. */ private var closed = false @@ -804,4 +809,26 @@ class FakeFileSystem private constructor( } override fun toString() = "FakeFileSystem" + + /** + * Obtain an Exclusive or Shared Lock on [path]. + * + * @throws IOException if [path] does not exist, the FileSystem or the path does not + * support file locking, or the lock cannot be acquired. This method does not wait for the lock + * to become available. + */ + @Throws(IOException::class) + override fun lock( + path: Path, + mode: LockMode, + ): FileLock { + val canonicalPath = canonicalizeInternal(path) + return locks.getOrPut(canonicalPath) { + FakeFileSystemLock(canonicalPath) + }.lock(mode) + } +} + +expect class FakeFileSystemLock constructor(path: Path) { + fun lock(mode: LockMode): FileLock } diff --git a/okio-fakefilesystem/src/jvmMain/kotlin/okio/fakefilesystem/FakeFileSystemLock.jvm.kt b/okio-fakefilesystem/src/jvmMain/kotlin/okio/fakefilesystem/FakeFileSystemLock.jvm.kt new file mode 100644 index 0000000000..9ee754be4e --- /dev/null +++ b/okio-fakefilesystem/src/jvmMain/kotlin/okio/fakefilesystem/FakeFileSystemLock.jvm.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2025 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package okio.fakefilesystem + +import java.util.concurrent.locks.StampedLock +import okio.FileLock +import okio.IOException +import okio.LockMode +import okio.Path + +actual class FakeFileSystemLock actual constructor( + private val path: Path, +) { + private val readWriteLock = StampedLock() + + actual fun lock(mode: LockMode): FileLock { + val stamp = when (mode) { + LockMode.Shared -> readWriteLock.tryReadLock() + LockMode.Exclusive -> readWriteLock.tryWriteLock() + } + + if (stamp == 0L) { + throw IOException("Could not lock $path") + } + + return object : FileLock { + override val isShared: Boolean + get() = mode == LockMode.Shared + + override val isValid: Boolean + get() = readWriteLock.validate(stamp) + + override fun close() { + readWriteLock.unlock(stamp) + } + } + } +} diff --git a/okio-fakefilesystem/src/nonJvmMain/kotlin/okio/fakefilesystem/FakeFileSystemLock.nonJvm.kt b/okio-fakefilesystem/src/nonJvmMain/kotlin/okio/fakefilesystem/FakeFileSystemLock.nonJvm.kt new file mode 100644 index 0000000000..74b33b9a1d --- /dev/null +++ b/okio-fakefilesystem/src/nonJvmMain/kotlin/okio/fakefilesystem/FakeFileSystemLock.nonJvm.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2025 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okio.fakefilesystem + +import okio.FileLock +import okio.LockMode +import okio.Path + +actual class FakeFileSystemLock actual constructor(path: Path) { + actual fun lock(mode: LockMode): FileLock { + TODO("Not yet implemented") + } +} diff --git a/okio/api/okio.api b/okio/api/okio.api index 05c4ab6191..0eab2d6f02 100644 --- a/okio/api/okio.api +++ b/okio/api/okio.api @@ -432,6 +432,12 @@ public abstract class okio/FileHandle : java/io/Closeable { public final fun write (J[BII)V } +public abstract interface class okio/FileLock : java/lang/AutoCloseable { + public fun getFileHandle ()Lokio/FileHandle; + public abstract fun isShared ()Z + public abstract fun isValid ()Z +} + public final class okio/FileMetadata { public fun ()V public fun (ZZLokio/Path;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/util/Map;)V @@ -486,6 +492,8 @@ public abstract class okio/FileSystem : java/io/Closeable { public final fun listRecursively (Lokio/Path;)Lkotlin/sequences/Sequence; public fun listRecursively (Lokio/Path;Z)Lkotlin/sequences/Sequence; public static synthetic fun listRecursively$default (Lokio/FileSystem;Lokio/Path;ZILjava/lang/Object;)Lkotlin/sequences/Sequence; + public fun lock (Lokio/Path;Lokio/LockMode;)Lokio/FileLock; + public static synthetic fun lock$default (Lokio/FileSystem;Lokio/Path;Lokio/LockMode;ILjava/lang/Object;)Lokio/FileLock; public final fun metadata (Lokio/Path;)Lokio/FileMetadata; public abstract fun metadataOrNull (Lokio/Path;)Lokio/FileMetadata; public abstract fun openReadOnly (Lokio/Path;)Lokio/FileHandle; @@ -638,6 +646,14 @@ public final class okio/InflaterSource : okio/Source { public fun timeout ()Lokio/Timeout; } +public final class okio/LockMode : java/lang/Enum { + public static final field Exclusive Lokio/LockMode; + public static final field Shared Lokio/LockMode; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lokio/LockMode; + public static fun values ()[Lokio/LockMode; +} + public final class okio/Okio { public static final fun appendingSink (Ljava/io/File;)Lokio/Sink; public static final fun asResourceFileSystem (Ljava/lang/ClassLoader;)Lokio/FileSystem; diff --git a/okio/src/commonMain/kotlin/okio/FileSystem.kt b/okio/src/commonMain/kotlin/okio/FileSystem.kt index 5797e17b9c..a1eb66212b 100644 --- a/okio/src/commonMain/kotlin/okio/FileSystem.kt +++ b/okio/src/commonMain/kotlin/okio/FileSystem.kt @@ -391,6 +391,19 @@ expect abstract class FileSystem() : Closeable { @Throws(IOException::class) override fun close() + /** + * Obtain an Exclusive or Shared Lock on [path]. + * + * @throws IOException if [path] does not exist, the FileSystem or the path does not + * support file locking, or the lock cannot be acquired. This method does not wait for the lock + * to become available. + */ + @Throws(IOException::class) + open fun lock( + path: Path, + mode: LockMode = LockMode.Exclusive, + ): FileLock + companion object { /** * Returns a writable temporary directory on [SYSTEM]. @@ -408,3 +421,16 @@ expect abstract class FileSystem() : Closeable { val SYSTEM_TEMPORARY_DIRECTORY: Path } } + +enum class LockMode { + Exclusive, + Shared, +} + +interface FileLock : AutoCloseable { + val isShared: Boolean + val isValid: Boolean + + val fileHandle: FileHandle + get() = TODO() +} diff --git a/okio/src/commonTest/kotlin/okio/BaseFileLockTest.kt b/okio/src/commonTest/kotlin/okio/BaseFileLockTest.kt new file mode 100644 index 0000000000..1dad51688d --- /dev/null +++ b/okio/src/commonTest/kotlin/okio/BaseFileLockTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2025 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okio + +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +abstract class BaseFileLockTest { + abstract val fileSystem: FileSystem + abstract val file1: Path + + abstract fun isSupported(): Boolean + + abstract fun isSharedLockSupported(): Boolean + + @BeforeTest + fun createFile() { + fileSystem.write(file1) {} + } + + @Test + fun testLockAndUnlock() { + if (!isSupported()) { + return + } + + val lock = fileSystem.lock(file1, mode = LockMode.Exclusive) + + assertTrue(lock.isValid) + assertFalse(lock.isShared) + + lock.close() + assertFalse(lock.isValid) + } + + @Test + fun testExclusiveLock() { + if (!isSupported()) { + return + } + + val lock1 = fileSystem.lock(file1, mode = LockMode.Exclusive) + + assertTrue(lock1.isValid) + + val x2 = assertFailsWith { fileSystem.lock(file1, mode = LockMode.Exclusive) } + assertLockFailure(x2, file1) + + val x3 = assertFailsWith { fileSystem.lock(file1, mode = LockMode.Shared) } + assertLockFailure(x3, file1) + + lock1.close() + + val lock4 = fileSystem.lock(file1, mode = LockMode.Exclusive) + assertTrue(lock4.isValid) + } + + @Test + fun testSharedLock() { + if (!isSupported()) { + return + } + + val lock1 = fileSystem.lock(file1, mode = LockMode.Shared) + + assertTrue(lock1.isValid) + val x2 = assertFailsWith { fileSystem.lock(file1, mode = LockMode.Exclusive) } + assertLockFailure(x2, file1) + + val lock3 = if (isSharedLockSupported()) { + val lock3 = fileSystem.lock(file1, mode = LockMode.Shared) + assertTrue(lock3.isValid) + lock3 + } else { + val x3 = assertFailsWith { fileSystem.lock(file1, mode = LockMode.Shared) } + assertLockFailure(x3, file1) + null + } + + assertTrue(lock1.isValid) + + lock1.close() + + if (lock3 != null) { + val x4 = assertFailsWith { fileSystem.lock(file1, mode = LockMode.Exclusive) } + assertLockFailure(x4, file1) + + lock3.close() + } + + val lock5 = fileSystem.lock(file1, mode = LockMode.Exclusive) + assertTrue(lock5.isValid) + } + + abstract fun assertLockFailure(ex: Exception, path: Path) +} diff --git a/okio/src/jsMain/kotlin/okio/FileSystem.kt b/okio/src/jsMain/kotlin/okio/FileSystem.kt index 375851f6f0..678c0b3e86 100644 --- a/okio/src/jsMain/kotlin/okio/FileSystem.kt +++ b/okio/src/jsMain/kotlin/okio/FileSystem.kt @@ -98,6 +98,13 @@ actual abstract class FileSystem : Closeable { actual override fun close() { } + actual open fun lock( + path: Path, + mode: LockMode, + ): FileLock { + throw IOException("This file system does not support locking.") + } + actual companion object { actual val SYSTEM_TEMPORARY_DIRECTORY: Path = tmpdir.toPath() } diff --git a/okio/src/jvmMain/kotlin/okio/FileSystem.kt b/okio/src/jvmMain/kotlin/okio/FileSystem.kt index 95924f4cfd..f3399ca147 100644 --- a/okio/src/jvmMain/kotlin/okio/FileSystem.kt +++ b/okio/src/jvmMain/kotlin/okio/FileSystem.kt @@ -16,6 +16,7 @@ package okio import java.nio.file.FileSystem as JavaNioFileSystem +import kotlin.Throws import kotlin.contracts.InvocationKind import kotlin.contracts.contract import okio.Path.Companion.toPath @@ -143,6 +144,14 @@ actual abstract class FileSystem : Closeable { actual override fun close() { } + @Throws(IOException::class) + actual open fun lock( + path: Path, + mode: LockMode, + ): FileLock { + throw IOException("This file system does not support locking.") + } + actual companion object { /** * The current process's host file system. Use this instance directly, or dependency inject a diff --git a/okio/src/jvmMain/kotlin/okio/JvmSystemFileSystem.kt b/okio/src/jvmMain/kotlin/okio/JvmSystemFileSystem.kt index 8584dfb584..e9ba3189ca 100644 --- a/okio/src/jvmMain/kotlin/okio/JvmSystemFileSystem.kt +++ b/okio/src/jvmMain/kotlin/okio/JvmSystemFileSystem.kt @@ -17,6 +17,9 @@ package okio import java.io.InterruptedIOException import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.channels.OverlappingFileLockException +import java.nio.file.StandardOpenOption import okio.Path.Companion.toOkioPath /** @@ -143,6 +146,26 @@ internal open class JvmSystemFileSystem : FileSystem() { throw IOException("unsupported") } + @Suppress("NewApi") + @Throws(IOException::class) + override fun lock( + path: Path, + mode: LockMode, + ): FileLock = lock(path.toNioPath(), mode) + + class JvmFileLock(val fileLock: java.nio.channels.FileLock) : FileLock { + override val isShared: Boolean + get() = fileLock.isShared + override val isValid: Boolean + get() = fileLock.isValid + + override fun close() { + // Close the channel, since it has to be open anyway. + // And avoids a possible IOException. + fileLock.channel().close() + } + } + override fun toString() = "JvmSystemFileSystem" // We have to implement existence verification non-atomically on the JVM because there's no API @@ -154,4 +177,26 @@ internal open class JvmSystemFileSystem : FileSystem() { private fun Path.requireCreate() { if (exists(this)) throw IOException("$this already exists.") } + + companion object { + @Suppress("NewApi") + @Throws(IOException::class) + fun lock( + nioPath: java.nio.file.Path, + mode: LockMode, + ): FileLock { + val channel = FileChannel.open( + nioPath, + if (mode == LockMode.Exclusive) StandardOpenOption.APPEND else StandardOpenOption.READ, + ) + + try { + val lock = channel.lock(0, Long.MAX_VALUE, mode == LockMode.Shared) + + return JvmFileLock(lock) + } catch (e: OverlappingFileLockException) { + throw IOException("Could not lock $nioPath", e) + } + } + } } diff --git a/okio/src/jvmMain/kotlin/okio/NioFileSystemWrappingFileSystem.kt b/okio/src/jvmMain/kotlin/okio/NioFileSystemWrappingFileSystem.kt index c1a3c337c4..88cb0e2949 100644 --- a/okio/src/jvmMain/kotlin/okio/NioFileSystemWrappingFileSystem.kt +++ b/okio/src/jvmMain/kotlin/okio/NioFileSystemWrappingFileSystem.kt @@ -192,4 +192,11 @@ internal class NioFileSystemWrappingFileSystem(private val nioFileSystem: NioFil } override fun toString() = nioFileSystem::class.simpleName!! + + @Suppress("NewApi") + @Throws(IOException::class) + override fun lock( + path: Path, + mode: LockMode, + ): FileLock = lock(path.resolve(), mode) } diff --git a/okio/src/jvmTest/kotlin/okio/FakeFileLockTest.kt b/okio/src/jvmTest/kotlin/okio/FakeFileLockTest.kt new file mode 100644 index 0000000000..a6d9ce2c6b --- /dev/null +++ b/okio/src/jvmTest/kotlin/okio/FakeFileLockTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2025 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okio + +import kotlin.test.assertEquals +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem + +class FakeFileLockTest : BaseFileLockTest() { + override val fileSystem: FileSystem = FakeFileSystem() + + override val file1: Path + get() = "/file1".toPath() + + override fun isSupported(): Boolean = true + + override fun isSharedLockSupported(): Boolean = true + + override fun assertLockFailure(ex: Exception, path: Path) { + assertEquals("Could not lock $path", ex.message) + } +} diff --git a/okio/src/jvmTest/kotlin/okio/JvmFileLockTest.kt b/okio/src/jvmTest/kotlin/okio/JvmFileLockTest.kt new file mode 100644 index 0000000000..e11e303434 --- /dev/null +++ b/okio/src/jvmTest/kotlin/okio/JvmFileLockTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2025 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okio + +import kotlin.test.assertContains +import okio.Path.Companion.toOkioPath +import okio.TestUtil.isWindows +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class JvmFileLockTest() : BaseFileLockTest() { + @JvmField + @Rule + var temporaryFolder = TemporaryFolder() + + override val fileSystem: FileSystem + get() = FileSystem.SYSTEM + + override val file1: Path by lazy { temporaryFolder.newFile("file1").toOkioPath() } + + override fun isSupported(): Boolean = true + + override fun isSharedLockSupported(): Boolean = !isWindows() + + override fun assertLockFailure(ex: Exception, path: Path) { + assertContains(ex.message!!, "Could not lock $path") + } +} diff --git a/okio/src/jvmTest/kotlin/okio/TestUtil.kt b/okio/src/jvmTest/kotlin/okio/TestUtil.kt index 3e5e954729..6bb7c2d99b 100644 --- a/okio/src/jvmTest/kotlin/okio/TestUtil.kt +++ b/okio/src/jvmTest/kotlin/okio/TestUtil.kt @@ -294,5 +294,8 @@ object TestUtil { return reversed.toShort() } - fun assumeNotWindows() = Assume.assumeFalse(System.getProperty("os.name").lowercase(Locale.getDefault()).contains("win")) + fun assumeNotWindows() = Assume.assumeFalse(isWindows()) + + fun isWindows(): Boolean = + System.getProperty("os.name").lowercase(Locale.getDefault()).contains("win") } diff --git a/okio/src/nativeMain/kotlin/okio/FileSystem.kt b/okio/src/nativeMain/kotlin/okio/FileSystem.kt index 726821896d..dd93f3226b 100644 --- a/okio/src/nativeMain/kotlin/okio/FileSystem.kt +++ b/okio/src/nativeMain/kotlin/okio/FileSystem.kt @@ -117,6 +117,14 @@ actual abstract class FileSystem : Closeable { actual override fun close() { } + @Throws(IOException::class) + actual open fun lock( + path: Path, + mode: LockMode, + ): FileLock { + throw IOException("This file system does not support locking.") + } + actual companion object { /** * The current process's host file system. Use this instance directly, or dependency inject a diff --git a/okio/src/wasmMain/kotlin/okio/FileSystem.kt b/okio/src/wasmMain/kotlin/okio/FileSystem.kt index e88a63d73f..fb106943e4 100644 --- a/okio/src/wasmMain/kotlin/okio/FileSystem.kt +++ b/okio/src/wasmMain/kotlin/okio/FileSystem.kt @@ -99,6 +99,14 @@ actual abstract class FileSystem : Closeable { actual override fun close() { } + @Throws(IOException::class) + actual open fun lock( + path: Path, + mode: LockMode, + ): FileLock { + throw IOException("This file system does not support locking.") + } + actual companion object { actual val SYSTEM_TEMPORARY_DIRECTORY: Path = "/tmp".toPath() }