diff options
author | Sorin Basca <sorinbasca@google.com> | 2024-01-02 13:53:35 +0000 |
---|---|---|
committer | Sorin Basca <sorinbasca@google.com> | 2024-01-02 13:53:35 +0000 |
commit | a75418b72b18516fe6a15bdcdeed2a3d74b55a5b (patch) | |
tree | 017105cdf675e5facc600b9a1ea09e15fabdac1f | |
parent | 71d77aa9ce22996dec43a463569dd5eee61fd063 (diff) | |
parent | 08af0faf025606573de5ea048d55cb857ca8f085 (diff) | |
download | okio-a75418b72b18516fe6a15bdcdeed2a3d74b55a5b.tar.gz |
Merge commit '08af0faf025606573de5ea048d55cb857ca8f085' into HEAD
Bug: 313924276
Test: m
Change-Id: I4782c05e8bb232bc37b005e052909572d8099430
54 files changed, 5143 insertions, 4 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 48028d0a..d3ebcebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,35 @@ Change Log ========== +## Version 3.0.0-alpha.1 + +_2021-01-07_ + +* New: Experimental file system API. The `Path`, `FileMetadata`, `FileSystem` and + `ForwardingFileSystem` types are subject to API changes in a future release. +* New: Experimental `okio-fakefilesystem` artifact. + + +## Version 2.10.0 + +_2021-01-07_ + +* New: Support Windows (mingwX64) in multiplatform. +* New: Support watchOS (watchosArm32, watchosArm64, watchosX86) in multiplatform. +* New: Support `HashingSource`, `HashingSink`, buffer hash functions, and `UnsafeCursor` on non-JVM + platforms. Previously these were all JVM-only. +* New: Implement `Closeable` on `Sink` and `Source` on non-JVM platforms. Okio now includes a + multiplatform `okio.Closeable` interface and corresponding `use {}` extension. Closing resources + when you're done with them shouldn't be JVM-only! +* New: `Sink.hashingSink` and `Source.hashingSource` functions that accept + `java.security.MessageDigest` and `javax.crypto.Mac` instances. Use these when your hash function + isn't built-in. +* Fix: Don't crash with a `ShortBufferException` in `CipherSink` and `CipherSource` on Android. + (Android may throw a `ShortBufferException` even if the buffer is not too short. We now + avoid this problem!) +* Upgrade: [Kotlin 1.4.20][kotlin_1_4_20]. + + ## Version 2.9.0 _2020-10-04_ @@ -14,7 +14,7 @@ third_party { type: GIT value: "https://github.com/square/okio/" } - version: "47fb0ddcd0bcf768a897dff723a1699341eea10f" - last_upgrade_date { year: 2021 month: 4 day: 6 } + version: "08af0faf025606573de5ea048d55cb857ca8f085" + last_upgrade_date { year: 2024 month: 1 day: 2 } license_type: NOTICE } diff --git a/android-test/build.gradle b/android-test/build.gradle index 56fcda76..6e792b67 100644 --- a/android-test/build.gradle +++ b/android-test/build.gradle @@ -44,6 +44,7 @@ android { sourceSets { named("androidTest") { it.java.srcDirs += [ + project.file("../okio-fakefilesystem/src/commonMain/kotlin"), project.file("../okio/src/commonMain/kotlin"), project.file("../okio/src/commonTest/java"), project.file("../okio/src/commonTest/kotlin"), diff --git a/docs/releasing.md b/docs/releasing.md index 94a1a34c..f0aac1cf 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -82,7 +82,7 @@ Cutting a Release gradle.properties sed -i "" \ "s/\"com.squareup.okio:\([^\:]*\):[^\"]*\"/\"com.squareup.okio:\1:$RELEASE_VERSION\"/g" \ - `find . -name "README.md"` + `find . -name "index.md"` ./gradlew clean publish ``` diff --git a/gradle.properties b/gradle.properties index 21f92f07..0c575bbc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ android.useAndroidX=true systemProp.org.gradle.internal.publish.checksums.insecure=true GROUP=com.squareup.okio -VERSION_NAME=2.11.0-SNAPSHOT +VERSION_NAME=3.0.0-SNAPSHOT POM_DESCRIPTION=A modern I/O API for Java diff --git a/okio-fakefilesystem/README.md b/okio-fakefilesystem/README.md new file mode 100644 index 00000000..a5220da5 --- /dev/null +++ b/okio-fakefilesystem/README.md @@ -0,0 +1,4 @@ +Okio Testing (pre-release) +-------------------------- + +This module contains test implementations of Okio types. diff --git a/okio-fakefilesystem/build.gradle b/okio-fakefilesystem/build.gradle new file mode 100644 index 00000000..6e4d55c6 --- /dev/null +++ b/okio-fakefilesystem/build.gradle @@ -0,0 +1,60 @@ +apply plugin: 'org.jetbrains.kotlin.multiplatform' + +kotlin { + jvm { + withJava() + } + if (kmpJsEnabled) { + js { + configure([compilations.main, compilations.test]) { + tasks.getByName(compileKotlinTaskName).kotlinOptions { + moduleKind = "umd" + sourceMap = true + metaInfo = true + } + } + nodejs { + testTask { + useMocha { + timeout = "30s" + } + } + } + } + } + if (kmpNativeEnabled) { + iosX64() + iosArm64() + watchosArm32() + watchosArm64() + watchosX86() + // Required to generate tests tasks: https://youtrack.jetbrains.com/issue/KT-26547 + linuxX64() + macosX64() + mingwX64() + } + sourceSets { + commonMain { + dependencies { + api deps.kotlin.stdLib.common + api deps.kotlin.time + api project(":okio") + } + } + } +} + +tasks.withType(JavaCompile) { + options.encoding = 'UTF-8' + targetCompatibility = JavaVersion.VERSION_1_8 +} + +// modify these lines for MANIFEST.MF properties or for specific bnd instructions +project.ext.bndManifest = ''' + Export-Package: okio.fakefilesystem + Automatic-Module-Name: okio.fakefilesystem + Bundle-SymbolicName: com.squareup.okio.fakefilesystem + ''' + +apply from: "$rootDir/okio/jvm/jvm.gradle" +apply from: "$rootDir/gradle/gradle-mvn-mpp-push.gradle" diff --git a/okio-fakefilesystem/gradle.properties b/okio-fakefilesystem/gradle.properties new file mode 100644 index 00000000..1820218e --- /dev/null +++ b/okio-fakefilesystem/gradle.properties @@ -0,0 +1,2 @@ +POM_ARTIFACT_ID=okio-fakefilesystem +POM_NAME=Okio Fake File System diff --git a/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt b/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt new file mode 100644 index 00000000..fb426744 --- /dev/null +++ b/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt @@ -0,0 +1,391 @@ +/* + * Copyright (C) 2020 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 kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import okio.Buffer +import okio.ByteString +import okio.ExperimentalFileSystem +import okio.FileMetadata +import okio.FileNotFoundException +import okio.FileSystem +import okio.IOException +import okio.Path +import okio.Path.Companion.toPath +import okio.Sink +import okio.Source +import okio.Timeout +import okio.fakefilesystem.FakeFileSystem.Element.Directory +import okio.fakefilesystem.FakeFileSystem.Element.File +import kotlin.jvm.JvmField +import kotlin.jvm.JvmName + +/** + * A fully in-memory file system useful for testing. It includes features to support writing + * better tests. + * + * Use [openPaths] to see which paths have been opened for read or write, but not yet closed. Tests + * should call [checkNoOpenFiles] in `tearDown()` to confirm that no file streams were leaked. + * + * By default this file system permits deletion and removal of open files. Configure + * [windowsLimitations] to true to throw an [IOException] when asked to delete or rename an open + * file. + */ +@ExperimentalFileSystem +class FakeFileSystem( + private val windowsLimitations: Boolean = false, + private val workingDirectory: Path = (if (windowsLimitations) "F:\\".toPath() else "/".toPath()), + + @JvmField + val clock: Clock = Clock.System +) : FileSystem() { + + init { + require(workingDirectory.isAbsolute) { + "expected an absolute path but was $workingDirectory" + } + } + + /** Keys are canonical paths. Each value is either a [Directory] or a [ByteString]. */ + private val elements = mutableMapOf<Path, Element>() + + /** Files that are currently open and need to be closed to avoid resource leaks. */ + private val openFiles = mutableListOf<OpenFile>() + + /** + * Canonical paths for every file and directory in this file system. This omits file system roots + * like `C:\` and `/`. + */ + @get:JvmName("allPaths") + val allPaths: Set<Path> + get() { + val result = mutableListOf<Path>() + for (path in elements.keys) { + if (path.isRoot) continue + result += path + } + result.sort() + return result.toSet() + } + + /** + * Canonical paths currently opened for reading or writing in the order they were opened. This may + * contain duplicates if a single path is open by multiple readers. + * + * Note that this may contain paths not present in [allPaths]. This occurs if a file is deleted + * while it is still open. + * + * The returned list is ordered by the order that the paths were opened. + */ + @get:JvmName("openPaths") + val openPaths: List<Path> + get() = openFiles.map { it.canonicalPath } + + /** + * Confirm that all files that have been opened on this file system (with [source], [sink], and + * [appendingSink]) have since been closed. Call this in your test's `tearDown()` function to + * confirm that your program hasn't leaked any open files. + * + * Forgetting to close a file on a real file system is a severe error that may lead to a program + * crash. The operating system enforces a limit on how many files may be open simultaneously. On + * Linux this is [getrlimit] and is commonly adjusted with the `ulimit` command. + * + * [getrlimit]: https://man7.org/linux/man-pages/man2/getrlimit.2.html + * + * @throws IllegalStateException if any files are open when this function is called. + */ + fun checkNoOpenFiles() { + val firstOpenFile = openFiles.firstOrNull() ?: return + throw IllegalStateException( + """ + |expected 0 open files, but found: + | ${openFiles.joinToString(separator = "\n ") { it.canonicalPath.toString() }} + """.trimMargin(), + firstOpenFile.backtrace + ) + } + + override fun canonicalize(path: Path): Path { + val canonicalPath = workingDirectory / path + + if (canonicalPath !in elements) { + throw FileNotFoundException("no such file: $path") + } + + return canonicalPath + } + + override fun metadataOrNull(path: Path): FileMetadata? { + val canonicalPath = workingDirectory / path + var element = elements[canonicalPath] + + // If the path is a root, create it on demand. + if (element == null && path.isRoot) { + element = Directory(createdAt = clock.now()) + elements[path] = element + } + + return element?.metadata + } + + override fun list(dir: Path): List<Path> { + val canonicalPath = workingDirectory / dir + val element = requireDirectory(canonicalPath) + + element.access(now = clock.now()) + val paths = elements.keys.filterTo(mutableListOf()) { it.parent == canonicalPath } + paths.sort() + return paths + } + + override fun source(file: Path): Source { + val canonicalPath = workingDirectory / file + val element = elements[canonicalPath] ?: throw FileNotFoundException("no such file: $file") + + if (element !is File) { + throw IOException("not a file: $file") + } + + val openFile = OpenFile(canonicalPath, Exception("file opened for reading here")) + openFiles += openFile + element.access(now = clock.now()) + return FakeFileSource(openFile, Buffer().write(element.data)) + } + + override fun sink(file: Path): Sink { + return newSink(file, append = false) + } + + override fun appendingSink(file: Path): Sink { + return newSink(file, append = true) + } + + private fun newSink(file: Path, append: Boolean): Sink { + val canonicalPath = workingDirectory / file + val now = clock.now() + + val existing = elements[canonicalPath] + if (existing is Directory) { + throw IOException("destination is a directory: $file") + } + val parent = requireDirectory(canonicalPath.parent) + parent.access(now, true) + + val openFile = OpenFile(canonicalPath, Exception("file opened for writing here")) + openFiles += openFile + val regularFile = File(createdAt = existing?.createdAt ?: now) + val result = FakeFileSink(openFile, regularFile) + if (append && existing is File) { + result.buffer.write(existing.data) + regularFile.data = existing.data + } + elements[canonicalPath] = regularFile + regularFile.access(now = now, modified = true) + return result + } + + override fun createDirectory(dir: Path) { + val canonicalPath = workingDirectory / dir + + if (elements[canonicalPath] != null) { + throw IOException("already exists: $dir") + } + requireDirectory(canonicalPath.parent) + + elements[canonicalPath] = Directory(createdAt = clock.now()) + } + + override fun atomicMove(source: Path, target: Path) { + val canonicalSource = workingDirectory / source + val canonicalTarget = workingDirectory / target + + val targetElement = elements[canonicalTarget] + val sourceElement = elements[canonicalSource] + + // Universal constraints. + if (targetElement is Directory) { + throw IOException("target is a directory: $target") + } + requireDirectory(canonicalTarget.parent) + if (windowsLimitations) { + // Windows-only constraints. + openFileOrNull(canonicalSource)?.let { + throw IOException("source is open $source", it.backtrace) + } + openFileOrNull(canonicalTarget)?.let { + throw IOException("target is open $target", it.backtrace) + } + } else { + // UNIX-only constraints. + if (sourceElement is Directory && targetElement is File) { + throw IOException("source is a directory and target is a file") + } + } + + val removed = elements.remove(canonicalSource) + ?: throw FileNotFoundException("source doesn't exist: $source") + elements[canonicalTarget] = removed + } + + override fun delete(path: Path) { + val canonicalPath = workingDirectory / path + + if (elements.keys.any { it.parent == canonicalPath }) { + throw IOException("non-empty directory") + } + + if (windowsLimitations) { + openFileOrNull(canonicalPath)?.let { + throw IOException("file is open $path", it.backtrace) + } + } + + if (elements.remove(canonicalPath) == null) { + throw FileNotFoundException("no such file: $path") + } + } + + /** + * Gets the directory at [path], creating it if [path] is a file system root. + * + * @throws IOException if the named directory is not a root and does not exist, or if it does + * exist but is not a directory. + */ + private fun requireDirectory(path: Path?): Directory { + if (path == null) throw IOException("directory does not exist") + + // If the path is a directory, return it! + val element = elements[path] + if (element is Directory) return element + + // If the path is a root, create a directory for it on demand. + if (path.isRoot) { + val root = Directory(createdAt = clock.now()) + elements[path] = root + return root + } + + if (element == null) throw FileNotFoundException("no such directory: $path") + + throw IOException("not a directory: $path") + } + + private sealed class Element( + val createdAt: Instant + ) { + var lastModifiedAt: Instant = createdAt + var lastAccessedAt: Instant = createdAt + + class File(createdAt: Instant) : Element(createdAt) { + var data: ByteString = ByteString.EMPTY + + override val metadata: FileMetadata + get() = FileMetadata( + isRegularFile = true, + size = data.size.toLong(), + createdAt = createdAt, + lastModifiedAt = lastModifiedAt, + lastAccessedAt = lastAccessedAt + ) + } + + class Directory(createdAt: Instant) : Element(createdAt) { + override val metadata: FileMetadata + get() = FileMetadata( + isDirectory = true, + createdAt = createdAt, + lastModifiedAt = lastModifiedAt, + lastAccessedAt = lastAccessedAt + ) + } + + fun access(now: Instant, modified: Boolean = false) { + lastAccessedAt = now + if (modified) { + lastModifiedAt = now + } + } + + abstract val metadata: FileMetadata + } + + private fun openFileOrNull(canonicalPath: Path): OpenFile? { + return openFiles.firstOrNull { it.canonicalPath == canonicalPath } + } + + private class OpenFile( + val canonicalPath: Path, + val backtrace: Throwable + ) + + /** Reads data from [buffer], removing itself from [openPathsMutable] when closed. */ + private inner class FakeFileSource( + private val openFile: OpenFile, + private val buffer: Buffer + ) : Source { + private var closed = false + + override fun read(sink: Buffer, byteCount: Long): Long { + check(!closed) { "closed" } + return buffer.read(sink, byteCount) + } + + override fun timeout() = Timeout.NONE + + override fun close() { + if (closed) return + closed = true + openFiles -= openFile + } + + override fun toString() = "source(${openFile.canonicalPath})" + } + + /** Writes data to [path]. */ + private inner class FakeFileSink( + private val openFile: OpenFile, + private val file: File + ) : Sink { + val buffer = Buffer() + private var closed = false + + override fun write(source: Buffer, byteCount: Long) { + check(!closed) { "closed" } + buffer.write(source, byteCount) + } + + override fun flush() { + check(!closed) { "closed" } + file.data = buffer.snapshot() + file.access(now = clock.now(), modified = true) + } + + override fun timeout() = Timeout.NONE + + override fun close() { + if (closed) return + closed = true + file.data = buffer.snapshot() + file.access(now = clock.now(), modified = true) + openFiles -= openFile + } + + override fun toString() = "sink(${openFile.canonicalPath})" + } + + override fun toString() = "FakeFileSystem" +} diff --git a/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/time.kt b/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/time.kt new file mode 100644 index 00000000..d3f3e6b6 --- /dev/null +++ b/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/time.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2020 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:JvmName("-Time") +package okio.fakefilesystem + +import kotlinx.datetime.Instant +import okio.ExperimentalFileSystem +import okio.FileMetadata +import kotlin.jvm.JvmName + +@JvmName("newFileMetadata") +@ExperimentalFileSystem +internal fun FileMetadata( + isRegularFile: Boolean = false, + isDirectory: Boolean = false, + size: Long? = null, + createdAt: Instant? = null, + lastModifiedAt: Instant? = null, + lastAccessedAt: Instant? = null +): FileMetadata { + return FileMetadata( + isRegularFile = isRegularFile, + isDirectory = isDirectory, + size = size, + createdAtMillis = createdAt?.toEpochMilliseconds(), + lastModifiedAtMillis = lastModifiedAt?.toEpochMilliseconds(), + lastAccessedAtMillis = lastAccessedAt?.toEpochMilliseconds() + ) +} diff --git a/okio/build.gradle b/okio/build.gradle index 980ecb0a..24502c6c 100644 --- a/okio/build.gradle +++ b/okio/build.gradle @@ -81,6 +81,8 @@ kotlin { implementation deps.kotlin.test.common implementation deps.kotlin.test.annotations implementation deps.kotlin.time + + implementation project(":okio-fakefilesystem") } } nonJvmMain { diff --git a/okio/src/appleMain/kotlin/okio/posixVariant.kt b/okio/src/appleMain/kotlin/okio/posixVariant.kt new file mode 100644 index 00000000..69bbd792 --- /dev/null +++ b/okio/src/appleMain/kotlin/okio/posixVariant.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import platform.posix.ENOENT +import platform.posix.S_IFDIR +import platform.posix.S_IFMT +import platform.posix.S_IFREG +import platform.posix.errno +import platform.posix.stat + +@ExperimentalFileSystem +internal actual fun PosixFileSystem.variantMetadataOrNull(path: Path): FileMetadata? { + return memScoped { + val stat = alloc<stat>() + if (platform.posix.lstat(path.toString(), stat.ptr) != 0) { + if (errno == ENOENT) return null + throw errnoToIOException(errno) + } + return@memScoped FileMetadata( + isRegularFile = stat.st_mode.toInt() and S_IFMT == S_IFREG, + isDirectory = stat.st_mode.toInt() and S_IFMT == S_IFDIR, + size = stat.st_size, + createdAtMillis = stat.st_ctimespec.epochMillis, + lastModifiedAtMillis = stat.st_mtimespec.epochMillis, + lastAccessedAtMillis = stat.st_atimespec.epochMillis + ) + } +} diff --git a/okio/src/commonMain/kotlin/okio/-Platform.kt b/okio/src/commonMain/kotlin/okio/-Platform.kt index 4790d3cd..a2ae9262 100644 --- a/okio/src/commonMain/kotlin/okio/-Platform.kt +++ b/okio/src/commonMain/kotlin/okio/-Platform.kt @@ -16,6 +16,12 @@ package okio +@ExperimentalFileSystem +internal expect val PLATFORM_FILE_SYSTEM: FileSystem + +@ExperimentalFileSystem +internal expect val PLATFORM_TEMPORARY_DIRECTORY: Path + internal expect fun ByteArray.toUtf8String(): String internal expect fun String.asUtf8ToByteArray(): ByteArray diff --git a/okio/src/commonMain/kotlin/okio/FileMetadata.kt b/okio/src/commonMain/kotlin/okio/FileMetadata.kt new file mode 100644 index 00000000..4c9d2070 --- /dev/null +++ b/okio/src/commonMain/kotlin/okio/FileMetadata.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2020 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 + +/** + * Description of a file or another object referenced by a path. + * + * In simple use a file system is a mechanism for organizing files and directories on a local + * storage device. In practice file systems are more capable and their contents more varied. For + * example, a path may refer to: + * + * * An operating system process that consumes data, produces data, or both. For example, reading + * from the `/dev/urandom` file on Linux returns a unique sequence of pseudorandom bytes to each + * reader. + * + * * A stream that connects a pair of programs together. A pipe is a special file that a producing + * program writes to and a consuming program reads from. Both programs operate concurrently. The + * size of a pipe is not well defined: the writer can write as much data as the reader is able to + * read. + * + * * A file on a remote file system. The performance and availability of remote files may be quite + * different from that of local files! + * + * * A symbolic link (symlink) to another path. When attempting to access this path the file system + * will follow the link and return data from the target path. + * + * * The same content as another path without a symlink. On UNIX file systems an inode is an + * anonymous handle to a file's content, and multiple paths may target the same inode without any + * other relationship to one another. A consequence of this design is that a directory with three + * 1 GiB files may only need 1 GiB on the storage device. + * + * This class does not attempt to model these rich file system features! It exposes a limited view + * useful for programs with only basic file system needs. Be cautious of the potential consequences + * of special files when writing programs that operate on a file system. + * + * File metadata is subject to change, and code that operates on file systems should defend against + * changes to the file that occur between reading metadata and subsequent operations. + */ +@ExperimentalFileSystem +class FileMetadata( + /** True if this file is a container of bytes. If this is true, then [size] is non-null. */ + val isRegularFile: Boolean, + + /** True if the path refers to a directory that contains 0 or more child paths. */ + val isDirectory: Boolean, + + /** + * Returns the number of bytes readable from this file. The amount of storage resources consumed + * by this file may be larger (due to block size overhead, redundant copies for RAID, etc.), or + * smaller (due to file system compression, shared inodes, etc). + */ + val size: Long?, + + /** + * Returns the system time of the host computer when this file was created, if the host file + * system supports this feature. This is typically available on Windows NTFS file systems and not + * available on UNIX or Windows FAT file systems. + */ + val createdAtMillis: Long?, + + /** + * Returns the system time of the host computer when this file was most recently written. + * + * Note that the accuracy of the returned time may be much more coarse than its precision. In + * particular, this value is expressed with millisecond precision but may be accessed at + * second- or day-accuracy only. + */ + val lastModifiedAtMillis: Long?, + + /** + * Returns the system time of the host computer when this file was most recently read or written. + * + * Note that the accuracy of the returned time may be much more coarse than its precision. In + * particular, this value is expressed with millisecond precision but may be accessed at + * second- or day-accuracy only. + */ + val lastAccessedAtMillis: Long? +) { + override fun equals(other: Any?) = other is FileMetadata && toString() == other.toString() + + override fun hashCode() = toString().hashCode() + + override fun toString(): String { + val fields = mutableListOf<String>() + if (isRegularFile) fields += "isRegularFile" + if (isDirectory) fields += "isDirectory" + if (size != null) fields += "byteCount=$size" + if (createdAtMillis != null) fields += "createdAt=$createdAtMillis" + if (lastModifiedAtMillis != null) fields += "lastModifiedAt=$lastModifiedAtMillis" + if (lastAccessedAtMillis != null) fields += "lastAccessedAt=$lastAccessedAtMillis" + return fields.joinToString(separator = ", ", prefix = "FileMetadata(", postfix = ")") + } +} diff --git a/okio/src/commonMain/kotlin/okio/FileSystem.kt b/okio/src/commonMain/kotlin/okio/FileSystem.kt new file mode 100644 index 00000000..8bf42f8a --- /dev/null +++ b/okio/src/commonMain/kotlin/okio/FileSystem.kt @@ -0,0 +1,360 @@ +/* + * Copyright (C) 2020 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 okio.FileSystem.Companion.SYSTEM +import kotlin.jvm.JvmField + +/** + * Read and write access to a hierarchical collection of files, addressed by [paths][Path]. This + * is a natural interface to the [current computer's local file system][SYSTEM]. + * + * Not Just the Local File System + * ------------------------------ + * + * Other implementations are possible: + * + * * `FakeFileSystem` is an in-memory file system suitable for testing. Note that this class is + * included in the `okio-fakefilesystem` artifact. + * + * * A ZIP file system could provide access to the contents of a `.zip` file. + * + * * A remote file system could access files over the network. + * + * * A [decorating file system][ForwardingFileSystem] could apply monitoring, encryption, + * compression, or filtering to another file system implementation. + * + * For improved capability and testability, consider structuring your classes to dependency inject + * a `FileSystem` rather than using [SYSTEM] directly. + * + * Limited API + * ----------- + * + * This interface is limited in which file system features it supports. Applications that need rich + * file system features should use another API, possibly alongside this API. + * + * This class cannot create special file types like hard links, symlinks, pipes, or mounts. Reading + * or writing these files works as if they were regular files. + * + * It cannot read or write file access control features like the UNIX `chmod` and Windows access + * control lists. It does honor these controls and will fail with an [IOException] if privileges + * are insufficient! + * + * It cannot lock files, or query which files are locked. + * + * It cannot watch the file system for changes. + * + * Multiplatform + * ------------- + * + * This class supports a matrix of Kotlin platforms (JVM, Kotlin/Native, Kotlin/JS) and operating + * systems (Linux, macOS, and Windows). It attempts to balance working similarly across platforms + * with being consistent with the local operating system. + * + * This is a blocking API which limits its applicability on concurrent Node.js services. File + * operations will block the event loop (and all JavaScript execution!) until they complete. + * + * It supports the path schemes of both Windows (like `C:\Users`) and UNIX (like `/home`). Note that + * path resolution rules differ by platform. + * + * Differences vs. Java IO APIs + * ---------------------------- + * + * The `java.io.File` class is Java's original file system API. The `delete` and `renameTo` methods + * return false if the operation failed. The `list` method returns null if the file isn't a + * directory or could not be listed. This class always throws `IOExceptions` when operations don't + * succeed. + * + * The `java.nio.Path` and `java.nio.Files` classes are the entry points of Java's new file system + * API. Each `Path` instance is scoped to a particular file system, though that is often implicit + * because the `Paths.get()` function automatically uses the default (ie. system) file system. + * In Okio's API paths are just identifiers; you must use a specific `FileSystem` object to do + * I/O with. + */ +@ExperimentalFileSystem +abstract class FileSystem { + /** + * Resolves [path] against the current working directory and symlinks in this file system. The + * returned path identifies the same file as [path], but with an absolute path that does not + * include any symbolic links. + * + * This is similar to `File.getCanonicalFile()` on the JVM and `realpath` on POSIX. Unlike + * `File.getCanonicalFile()`, this throws if the file doesn't exist. + * + * @throws IOException if [path] cannot be resolved. This will occur if the file doesn't exist, + * if the current working directory doesn't exist or is inaccessible, or if another failure + * occurs while resolving the path. + */ + @Throws(IOException::class) + abstract fun canonicalize(path: Path): Path + + /** + * Returns metadata of the file, directory, or object identified by [path]. + * + * @throws IOException if [path] does not exist or its metadata cannot be read. + */ + @Throws(IOException::class) + fun metadata(path: Path): FileMetadata { + return metadataOrNull(path) ?: throw FileNotFoundException("no such file: $path") + } + + /** + * Returns metadata of the file, directory, or object identified by [path]. This returns null if + * there is no file at [path]. + * + * @throws IOException if [path] cannot be accessed due to a connectivity problem, permissions + * problem, or other issue. + */ + @Throws(IOException::class) + abstract fun metadataOrNull(path: Path): FileMetadata? + + /** + * Returns true if [path] identifies an object on this file system. + * + * @throws IOException if [path] cannot be accessed due to a connectivity problem, permissions + * problem, or other issue. + */ + @Throws(IOException::class) + fun exists(path: Path): Boolean { + return metadataOrNull(path) != null + } + + /** + * Returns the children of the directory identified by [dir]. The returned list is sorted using + * natural ordering. + * + * @throws IOException if [dir] does not exist, is not a directory, or cannot be read. A directory + * cannot be read if the current process doesn't have access to [dir], or if there's a loop of + * symbolic links, or if any name is too long. + */ + @Throws(IOException::class) + abstract fun list(dir: Path): List<Path> + + /** + * Returns a source that reads the bytes of [file] from beginning to end. + * + * @throws IOException if [file] does not exist, is not a file, or cannot be read. A file cannot + * be read if the current process doesn't have access to [file], if there's a loop of symbolic + * links, or if any name is too long. + */ + @Throws(IOException::class) + abstract fun source(file: Path): Source + + /** + * Creates a source to read [file], executes [readerAction] to read it, and then closes the + * source. This is a compact way to read the contents of a file. + */ + inline fun <T> read(file: Path, readerAction: BufferedSource.() -> T): T { + return source(file).buffer().use { + it.readerAction() + } + } + + /** + * Returns a sink that writes bytes to [file] from beginning to end. If [file] already exists it + * will be replaced with the new data. + * + * @throws IOException if [file] cannot be written. A file cannot be written if its enclosing + * directory does not exist, if the current process doesn't have access to [file], if there's + * a loop of symbolic links, or if any name is too long. + */ + @Throws(IOException::class) + abstract fun sink(file: Path): Sink + + /** + * Creates a sink to write [file], executes [writerAction] to write it, and then closes the sink. + * This is a compact way to write a file. + */ + inline fun <T> write(file: Path, writerAction: BufferedSink.() -> T): T { + return sink(file).buffer().use { + it.writerAction() + } + } + + /** + * Returns a sink that appends bytes to the end of [file], creating it if it doesn't already + * exist. + * + * @throws IOException if [file] cannot be written. A file cannot be written if its enclosing + * directory does not exist, if the current process doesn't have access to [file], if there's + * a loop of symbolic links, or if any name is too long. + */ + @Throws(IOException::class) + abstract fun appendingSink(file: Path): Sink + + /** + * Creates a directory at the path identified by [dir]. + * + * @throws IOException if [dir]'s parent does not exist, is not a directory, or cannot be written. + * A directory cannot be created if it already exists, if the current process doesn't have + * access, if there's a loop of symbolic links, or if any name is too long. + */ + @Throws(IOException::class) + abstract fun createDirectory(dir: Path) + + /** + * Creates a directory at the path identified by [dir], and any enclosing parent path directories, + * recursively. + * + * @throws IOException if any [metadata] or [createDirectory] operation fails. + */ + @Throws(IOException::class) + fun createDirectories(dir: Path) { + // Compute the sequence of directories to create. + val directories = ArrayDeque<Path>() + var path: Path? = dir + while (path != null && !exists(path)) { + directories.addFirst(path) + path = path.parent + } + + // Create them. + for (toCreate in directories) { + createDirectory(toCreate) + } + } + + /** + * Moves [source] to [target] in-place if the underlying file system supports it. If [target] + * exists, it is first removed. If `source == target`, this operation does nothing. This may be + * used to move a file or a directory. + * + * **Only as Atomic as the Underlying File System Supports** + * + * FAT and NTFS file systems cannot atomically move a file over an existing file. If the target + * file already exists, the move is performed into two steps: + * + * 1. Atomically delete the target file. + * 2. Atomically rename the source file to the target file. + * + * The delete step and move step are each atomic but not atomic in aggregate! If this process + * crashes, the host operating system crashes, or the hardware fails it is possible that the + * delete step will succeed and the rename will not. + * + * **Entire-file or nothing** + * + * These are the possible results of this operation: + * + * * This operation returns normally, the source file is absent, and the target file contains the + * data previously held by the source file. This is the success case. + * + * * The operation throws an [IOException] and the file system is unchanged. For example, this + * occurs if this process lacks permissions to perform the move. + * + * * This operation throws an [IOException], the target file is deleted, but the source file is + * unchanged. This is the partial failure case described above and is only possible on + * file systems like FAT and NTFS that do not support atomic file replacement. Typically in + * such cases this operation won't return at all because the process or operating system has + * also crashed. + * + * There is no failure mode where the target file holds a subset of the bytes of the source file. + * If the rename step cannot be performed atomically, this function will throw an [IOException] + * before attempting a move. Typically this occurs if the source and target files are on different + * physical volumes. + * + * **Non-Atomic Moves** + * + * If you need to move files across volumes, use [copy] followed by [delete], and change your + * application logic to recover should the copy step suffer a partial failure. + * + * @throws IOException if the move cannot be performed, or cannot be performed atomically. Moves + * fail if the source doesn't exist, if the target is not writable, if the target already + * exists and cannot be replaced, or if the move would cause physical or quota limits to be + * exceeded. This list of potential problems is not exhaustive. + */ + @Throws(IOException::class) + abstract fun atomicMove(source: Path, target: Path) + + /** + * Copies all of the bytes from the file at [source] to the file at [target]. This does not copy + * file metadata like last modified time, permissions, or extended attributes. + * + * This function is not atomic; a failure may leave [target] in an inconsistent state. For + * example, [target] may be empty or contain only a prefix of [source]. + * + * @throws IOException if [source] cannot be read or if [target] cannot be written. + */ + @Throws(IOException::class) + open fun copy(source: Path, target: Path) { + source(source).use { bytesIn -> + sink(target).buffer().use { bytesOut -> + bytesOut.writeAll(bytesIn) + } + } + } + + /** + * Deletes the file or directory at [path]. + * + * @throws IOException if there is nothing at [path] to delete, or if there is a file or directory + * but it could not be deleted. Deletes fail if the current process doesn't have access, if + * the file system is readonly, or if [path] is a non-empty directory. This list of potential + * problems is not exhaustive. + */ + @Throws(IOException::class) + abstract fun delete(path: Path) + + /** + * Recursively deletes all children of [fileOrDirectory] if it is a directory, then deletes + * [fileOrDirectory] itself. + * + * This function does not defend against race conditions. For example, if child files are created + * or deleted in [fileOrDirectory] while this function is executing, this may fail with an + * [IOException]. + * + * @throws IOException if any [metadata], [list], or [delete] operation fails. + */ + @Throws(IOException::class) + open fun deleteRecursively(fileOrDirectory: Path) { + val stack = ArrayDeque<Path>() + stack += fileOrDirectory + + while (stack.isNotEmpty()) { + val toDelete = stack.removeLast() + + val metadata = metadata(toDelete) + val children = if (metadata.isDirectory) list(toDelete) else listOf() + + if (children.isNotEmpty()) { + stack += toDelete + stack += children + } else { + delete(toDelete) + } + } + } + + companion object { + /** + * The current process's host file system. Use this instance directly, or dependency inject a + * [FileSystem] to make code testable. + */ + @JvmField + val SYSTEM: FileSystem = PLATFORM_FILE_SYSTEM + + /** + * Returns a writable temporary directory on [SYSTEM]. + * + * This is platform-specific. + * + * * **JVM and Android**: the path in the `java.io.tmpdir` system property + * * **Linux, iOS, and macOS**: the path in the `TMPDIR` environment variable. + * * **Windows**: the first non-null of `TEMP`, `TMP`, and `USERPROFILE` environment variables. + */ + @JvmField + val SYSTEM_TEMPORARY_DIRECTORY: Path = PLATFORM_TEMPORARY_DIRECTORY + } +} diff --git a/okio/src/commonMain/kotlin/okio/ForwardingFileSystem.kt b/okio/src/commonMain/kotlin/okio/ForwardingFileSystem.kt new file mode 100644 index 00000000..f6dddbb7 --- /dev/null +++ b/okio/src/commonMain/kotlin/okio/ForwardingFileSystem.kt @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2020 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.jvm.JvmName + +/** + * A [FileSystem] that forwards calls to another, intended for subclassing. + * + * ### Fault Injection + * + * You can use this to deterministically trigger file system failures in tests. This is useful to + * confirm that your program behaves correctly even if its file system operations fail. For example, + * this subclass fails every access of files named `unlucky.txt`: + * + * ``` + * val faultyFileSystem = object : ForwardingFileSystem(FileSystem.SYSTEM) { + * override fun onPathParameter(path: Path, functionName: String, parameterName: String): Path { + * if (path.name == "unlucky.txt") throw IOException("synthetic failure!") + * return path + * } + * } + * ``` + * + * You can fail specific operations by overriding them directly: + * + * ``` + * val faultyFileSystem = object : ForwardingFileSystem(FileSystem.SYSTEM) { + * override fun delete(path: Path) { + * throw IOException("synthetic failure!") + * } + * } + * ``` + * + * ### Observability + * + * You can extend this to verify which files your program accesses. This is a testing file system + * that records accesses as they happen: + * + * ``` + * class LoggingFileSystem : ForwardingFileSystem(FileSystem.SYSTEM) { + * val log = mutableListOf<String>() + * + * override fun onPathParameter(path: Path, functionName: String, parameterName: String): Path { + * log += "$functionName($parameterName=$path)" + * return path + * } + * } + * ``` + * + * This makes it easy for tests to assert exactly which files were accessed. + * + * ``` + * @Test + * fun testMergeJsonReports() { + * createSampleJsonReports() + * loggingFileSystem.log.clear() + * + * mergeJsonReports() + * + * assertThat(loggingFileSystem.log).containsExactly( + * "list(dir=json_reports)", + * "source(file=json_reports/2020-10.json)", + * "source(file=json_reports/2020-12.json)", + * "source(file=json_reports/2020-11.json)", + * "sink(file=json_reports/2020-all.json)" + * ) + * } + * ``` + * + * ### Transformations + * + * Subclasses can transform file names and content. + * + * For example, your program may be written to operate on a well-known directory like `/etc/` or + * `/System`. You can rewrite paths to make such operations safer to test. + * + * You may also transform file content to apply application-layer encryption or compression. This + * is particularly useful in situations where it's difficult or impossible to enable those features + * in the underlying file system. + * + * ### Abstract Functions Only + * + * Some file system functions like [copy] are implemented by using other features. These are the + * non-abstract functions in the [FileSystem] interface. + * + * **This class forwards only the abstract functions;** non-abstract functions delegate to the + * other functions of this class. If desired, subclasses may override non-abstract functions to + * forward them. + */ +@ExperimentalFileSystem +abstract class ForwardingFileSystem( + /** [FileSystem] to which this instance is delegating. */ + @get:JvmName("delegate") + val delegate: FileSystem +) : FileSystem() { + + /** + * Invoked each time a path is passed as a parameter to this file system. This returns the path to + * pass to [delegate], which should be [path] itself or a path on [delegate] that corresponds to + * it. + * + * Subclasses may override this to log accesses, fail on unexpected accesses, or map paths across + * file systems. + * + * The base implementation returns [path]. + * + * Note that this function will be called twice for calls to [atomicMove]; once for the source + * file and once for the target file. + * + * @param path the path passed to any of the functions of this. + * @param functionName a string like "canonicalize", "metadataOrNull", or "appendingSink". + * @param parameterName a string like "path", "file", "source", or "target". + * @return the path to pass to [delegate] for the same parameter. + */ + open fun onPathParameter(path: Path, functionName: String, parameterName: String): Path = path + + /** + * Invoked each time a path is returned by [delegate]. This returns the path to return to the + * caller, which should be [path] itself or a path on this that corresponds to it. + * + * Subclasses may override this to log accesses, fail on unexpected path accesses, or map + * directories or path names. + * + * The base implementation returns [path]. + * + * @param path the path returned by any of the functions of this. + * @param functionName a string like "canonicalize" or "list". + * @return the path to return to the caller. + */ + open fun onPathResult(path: Path, functionName: String): Path = path + + @Throws(IOException::class) + override fun canonicalize(path: Path): Path { + val path = onPathParameter(path, "canonicalize", "path") + val result = delegate.canonicalize(path) + return onPathResult(result, "canonicalize") + } + + @Throws(IOException::class) + override fun metadataOrNull(path: Path): FileMetadata? { + val path = onPathParameter(path, "metadataOrNull", "path") + return delegate.metadataOrNull(path) + } + + @Throws(IOException::class) + override fun list(dir: Path): List<Path> { + val dir = onPathParameter(dir, "list", "dir") + val result = delegate.list(dir) + val paths = result.mapTo(mutableListOf()) { onPathResult(it, "list") } + paths.sort() + return paths + } + + @Throws(IOException::class) + override fun source(file: Path): Source { + val file = onPathParameter(file, "source", "file") + return delegate.source(file) + } + + @Throws(IOException::class) + override fun sink(file: Path): Sink { + val file = onPathParameter(file, "sink", "file") + return delegate.sink(file) + } + + @Throws(IOException::class) + override fun appendingSink(file: Path): Sink { + val file = onPathParameter(file, "appendingSink", "file") + return delegate.appendingSink(file) + } + + @Throws(IOException::class) + override fun createDirectory(dir: Path) { + val dir = onPathParameter(dir, "createDirectory", "dir") + delegate.createDirectory(dir) + } + + @Throws(IOException::class) + override fun atomicMove(source: Path, target: Path) { + val source = onPathParameter(source, "atomicMove", "source") + val target = onPathParameter(target, "atomicMove", "target") + delegate.atomicMove(source, target) + } + + @Throws(IOException::class) + override fun delete(path: Path) { + val path = onPathParameter(path, "delete", "path") + delegate.delete(path) + } + + override fun toString() = "${this::class.simpleName}($delegate)" +} diff --git a/okio/src/commonMain/kotlin/okio/Path.kt b/okio/src/commonMain/kotlin/okio/Path.kt new file mode 100644 index 00000000..ff865167 --- /dev/null +++ b/okio/src/commonMain/kotlin/okio/Path.kt @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2020 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 okio.Path.Companion.toPath + +/** + * A hierarchical address on a file system. A path is an identifier only; a [FileSystem] is required + * to access the file that a path refers to, if any. + * + * UNIX and Windows Paths + * ---------------------- + * + * Paths follow different rules on UNIX vs. Windows operating systems. On UNIX operating systems + * (including Linux, Android, macOS, and iOS), the `/` slash character separates path segments. On + * Windows, the `\` backslash character separates path segments. The two platforms each have their + * own rules for path resolution. This class implements all rules on all platforms; for example you + * can model a Linux path in a native Windows application. + * + * Absolute and Relative Paths + * --------------------------- + * + * * **Absolute paths** identify a location independent of any working directory. On UNIX, absolute + * paths are prefixed with a slash, `/`. On Windows, absolute paths are one of two forms. The + * first is a volume letter, a colon, and a backslash, like `C:\`. The second is called a + * Universal Naming Convention (UNC) path, and it is prefixed by two backslashes `\\`. The term + * ‘fully-qualified path’ is a synonym of ‘absolute path’. + * + * * **Relative paths** are everything else. On their own, relative paths do not identify a + * location on a file system; they are relative to the system's current working directory. Use + * [FileSystem.canonicalize] to convert a relative path to its absolute path on a particular + * file system. + * + * There are some special cases when working with relative paths. + * + * On Windows, each volume (like `A:\` and `C:\`) has its own current working directory. A path + * prefixed with a volume letter and colon but no slash (like `A:letter.doc`) is relative to the + * working directory on the named volume. For example, if the working directory on `A:\` is + * `A:\jesse`, then the path `A:letter.doc` resolves to `A:\jesse\letter.doc`. + * + * The path string `C:\Windows` is an absolute path when following Windows rules and a relative + * path when following UNIX rules. For example, if the current working directory is + * `/Users/jesse`, then `C:\Windows` resolves to `/Users/jesse/C:/Windows`. + * + * This class decides which rules to follow by inspecting the first slash character in the path + * string. If the path contains no slash characters, it uses the host platform's rules. Or you may + * explicitly specify which rules to use by specifying the `directorySeparator` parameter in + * [toPath]. Pass `"/"` to get UNIX rules and `"\"` to get Windows rules. + * + * Path Traversal + * -------------- + * + * After the optional path root (like `/` on UNIX, like `X:\` or `\\` on Windows), the remainder of + * the path is a sequence of segments separated by `/` or `\` characters. Segments satisfy these + * rules: + * + * * Segments are always non-empty. + * * If the segment is `.`, then the full path must be `.`. + * * If the segment is `..`, then the the path must be relative. All `..` segments precede all + * other segments. + * + * The only path that ends with `/` is the file system root, `/`. The dot path `.` is a relative + * path that resolves to whichever path it is resolved against. + * + * The [name] is the last segment in a path. It is typically a file or directory name, like + * `README.md` or `Desktop`. The name may be another special value: + * + * * The empty string is the name of the file system root path (full path `/`). + * * `.` is the name of the identity relative path (full path `.`). + * * `..` is the name of a path consisting of only `..` segments (such as `../../..`). + * + * Comparing Paths + * --------------- + * + * Path implements [Comparable], [equals], and [hashCode]. If two paths are equal then they operate + * on the same file on the file system. + * + * Note that the converse is not true: **if two paths are non-equal, they may still resolve to the + * same file on the file system.** Here are some of the ways non-equal paths resolve to the same + * file: + * + * * **Case differences.** The default file system on macOS is case-insensitive. The paths + * `/Users/jesse/notes.txt` and `/USERS/JESSE/NOTES.TXT` are non-equal but these paths resolve to + * the same file. + * * **Mounting differences.** Volumes may be mounted at multiple paths. On macOS, + * `/Users/jesse/notes.txt` and `/Volumes/Macintosh HD/Users/jesse/notes.txt` typically resolve + * to the same file. On Windows, `C:\project\notes.txt` and `\\localhost\c$\project\notes.txt` + * typically resolve to the same file. + * * **Hard links.** UNIX file systems permit multiple paths to refer for same file. The paths may + * be wildly different, like `/Users/jesse/bruce_wayne.vcard` and + * `/Users/jesse/batman.vcard`, but changes via either path are reflected in both. + * * **Symlinks.** Symlinks permit multiple paths and directories to refer to the same file. On + * macOS `/tmp` is symlinked to `/private/tmp`, so `/tmp/notes.txt` and `/private/tmp/notes.txt` + * resolve to the same file. + * + * To test whether two paths refer to the same file, try [FileSystem.canonicalize] first. This + * follows symlinks and looks up the preserved casing for case-insensitive case-preserved paths. + * **This method does not guarantee a unique result, however.** For example, each hard link to a + * file may return its own canonical path. + * + * Paths are sorted in case-sensitive order. + * + * Sample Paths + * ------------ + * + * <table> + * <tr><th> Path <th> Parent <th> Name <th> Notes </tr> + * <tr><td> `/` <td> null <td> (empty) <td> root </tr> + * <tr><td> `/home/jesse/notes.txt` <td> `/home/jesse` <td> `notes.txt` <td> absolute path </tr> + * <tr><td> `project/notes.txt` <td> `project` <td> `notes.txt` <td> relative path </tr> + * <tr><td> `../../project/notes.txt` <td> `../../project` <td> `notes.txt` <td> relative path with traversal </tr> + * <tr><td> `../../..` <td> null <td> `..` <td> relative path with traversal </tr> + * <tr><td> `.` <td> null <td> `.` <td> current working directory </tr> + * <tr><td> `C:\` <td> null <td> (empty) <td> volume root (Windows) </tr> + * <tr><td> `C:\Windows\notepad.exe` <td> `C:\Windows` <td> `notepad.exe` <td> volume absolute path (Windows) </tr> + * <tr><td> `\` <td> null <td> (empty) <td> absolute path (Windows) </tr> + * <tr><td> `\Windows\notepad.exe` <td> `\Windows` <td> `notepad.exe` <td> absolute path (Windows) </tr> + * <tr><td> `C:` <td> null <td> (empty) <td> volume-relative path (Windows) </tr> + * <tr><td> `C:project\notes.txt` <td> `C:project` <td> `notes.txt` <td> volume-relative path (Windows) </tr> + * <tr><td> `\\server` <td> null <td> `server` <td> UNC server (Windows) </tr> + * <tr><td> `\\server\project\notes.txt` <td> `\\server\project` <td> `notes.txt` <td> UNC absolute path (Windows) </tr> + * </table> + */ +@ExperimentalFileSystem +expect class Path internal constructor(slash: ByteString, bytes: ByteString) : Comparable<Path> { + internal val slash: ByteString + internal val bytes: ByteString + + val isAbsolute: Boolean + + val isRelative: Boolean + + /** + * This is the volume letter like "C" on Windows paths that starts with a volume letter. For + * example, on the path "C:\Windows" this returns "C". This property is null if this is not a + * Windows path, or if it doesn't have a volume letter. + * + * Note that paths that start with a volume letter are not necessarily absolute paths. For + * example, the path "C:notepad.exe" is relative to whatever the current working directory is on + * the C: drive. + */ + val volumeLetter: Char? + + val nameBytes: ByteString + + val name: String + + /** + * Returns the path immediately enclosing this path. + * + * This returns null if this has no parent. That includes these paths: + * + * * The file system root (`/`) + * * The identity relative path (`.`) + * * A Windows volume root (like `C:\`) + * * A Windows Universal Naming Convention (UNC) root path (`\\server`) + * * A reference to the current working directory on a Windows volume (`C:`). + * * A series of relative paths (like `..` and `../..`). + */ + val parent: Path? + + /** + * Returns true if this is an absolute path with no parent. UNIX paths have a single root, `/`. + * Each volume on Windows is its own root, like `C:\` and `D:\`. Windows UNC paths like `\\server` + * are also roots. + */ + val isRoot: Boolean + + /** + * Returns a path that resolves [child] relative to this path. + * + * If [child] is an [absolute path][isAbsolute] or [has a volume letter][hasVolumeLetter] then + * this function is equivalent to `child.toPath()`. + */ + operator fun div(child: String): Path + + /** + * Returns a path that resolves [child] relative to this path. + * + * If [child] is an [absolute path][isAbsolute] or [has a volume letter][hasVolumeLetter] then + * this function is equivalent to `child.toPath()`. + */ + operator fun div(child: Path): Path + + override fun compareTo(other: Path): Int + + override fun equals(other: Any?): Boolean + + override fun hashCode(): Int + + override fun toString(): String + + companion object { + val DIRECTORY_SEPARATOR: String + + fun String.toPath(): Path + + fun String.toPath(directorySeparator: String?): Path + } +} diff --git a/okio/src/commonMain/kotlin/okio/internal/Path.kt b/okio/src/commonMain/kotlin/okio/internal/Path.kt new file mode 100644 index 00000000..ae924b47 --- /dev/null +++ b/okio/src/commonMain/kotlin/okio/internal/Path.kt @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2020 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.internal + +import okio.Buffer +import okio.ByteString +import okio.ByteString.Companion.encodeUtf8 +import okio.ExperimentalFileSystem +import okio.Path + +private val SLASH = "/".encodeUtf8() +private val BACKSLASH = "\\".encodeUtf8() +private val ANY_SLASH = "/\\".encodeUtf8() +private val DOT = ".".encodeUtf8() +private val DOT_DOT = "..".encodeUtf8() + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonIsAbsolute(): Boolean { + return bytes.startsWith(slash) || + (volumeLetter != null && bytes.size > 2 && bytes[2] == '\\'.toByte()) +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonIsRelative(): Boolean { + return !isAbsolute +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonVolumeLetter(): Char? { + if (slash != BACKSLASH) return null + if (bytes.size < 2) return null + if (bytes[1] != ':'.toByte()) return null + val c = bytes[0].toChar() + if (c !in 'a'..'z' && c !in 'A'..'Z') return null + return c +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonNameBytes(): ByteString { + val lastSlash = bytes.lastIndexOf(slash) + return when { + lastSlash != -1 -> bytes.substring(lastSlash + 1) + volumeLetter != null && bytes.size == 2 -> ByteString.EMPTY // "C:" has no name. + else -> bytes + } +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonName(): String { + return nameBytes.utf8() +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonParent(): Path? { + if (bytes == DOT || bytes == slash || lastSegmentIsDotDot()) { + return null // Terminal path. + } + + val lastSlash = bytes.lastIndexOf(slash) + when { + lastSlash == 2 && volumeLetter != null -> { + if (bytes.size == 3) return null // "C:\" has no parent. + return Path(slash, bytes.substring(endIndex = 3)) // Keep the trailing '\' in C:\. + } + lastSlash == 1 && bytes.startsWith(BACKSLASH) -> { + return null // "\\server" is a UNC path with no parent. + } + lastSlash == -1 && volumeLetter != null -> { + if (bytes.size == 2) return null // "C:" has no parent. + return Path(slash, bytes.substring(endIndex = 2)) // C: is volume-relative. + } + lastSlash == -1 -> { + return Path(slash, DOT) // Parent is the current working directory. + } + lastSlash == 0 -> { + return Path(slash, bytes.substring(endIndex = 1)) // Parent is the filesystem root '/'. + } + else -> { + return Path(slash, bytes.substring(endIndex = lastSlash)) + } + } +} + +@ExperimentalFileSystem +private fun Path.lastSegmentIsDotDot(): Boolean { + if (bytes.endsWith(DOT_DOT)) { + if (bytes.size == 2) return true // ".." is the whole string. + if (bytes.rangeEquals(bytes.size - 3, slash, 0, 1)) return true // Ends with "/.." or "\..". + } + return false +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonIsRoot(): Boolean { + return parent == null && isAbsolute +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonResolve(child: String): Path { + return div(Buffer().writeUtf8(child).toPath(slash)) +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonResolve(child: Path): Path { + if (child.isAbsolute || child.volumeLetter != null) return child + + val buffer = Buffer() + buffer.write(bytes) + if (buffer.size > 0) { + buffer.write(slash) + } + buffer.write(child.bytes) + return buffer.toPath(directorySeparator = slash) +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonCompareTo(other: Path): Int { + val bytesResult = bytes.compareTo(other.bytes) + if (bytesResult != 0) return bytesResult + return slash.compareTo(other.slash) +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonEquals(other: Any?): Boolean { + return other is Path && other.bytes == bytes && other.slash == slash +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonHashCode(): Int { + return bytes.hashCode() xor slash.hashCode() +} + +@ExperimentalFileSystem +@Suppress("NOTHING_TO_INLINE") +internal inline fun Path.commonToString(): String { + return bytes.utf8() +} + +@ExperimentalFileSystem +fun String.commonToPath(directorySeparator: String? = null): Path { + return Buffer().writeUtf8(this).toPath(directorySeparator?.toSlash()) +} + +/** Consume the buffer and return it as a path. */ +@ExperimentalFileSystem +internal fun Buffer.toPath(directorySeparator: ByteString? = null): Path { + var slash = directorySeparator + val result = Buffer() + + // Consume the absolute path prefix, like `/`, `\\`, `C:`, or `C:\` and write the + // canonicalized prefix to result. + var leadingSlashCount = 0 + while (rangeEquals(0L, SLASH) || rangeEquals(0L, BACKSLASH)) { + val byte = readByte() + slash = slash ?: byte.toSlash() + leadingSlashCount++ + } + if (leadingSlashCount >= 2 && slash == BACKSLASH) { + // This is a Windows UNC path, like \\server\directory\file.txt. + result.write(slash) + result.write(slash) + } else if (leadingSlashCount > 0) { + // This is platform-dependent: + // * On UNIX: a absolute path like /home + // * On Windows: this is relative to the current volume, like \Windows. + result.write(slash!!) + } else { + // This path doesn't start with any slash. We must initialize the slash character to use. + val limit = indexOfElement(ANY_SLASH) + slash = slash ?: when (limit) { + -1L -> Path.DIRECTORY_SEPARATOR.toSlash() + else -> get(limit).toSlash() + } + if (startsWithVolumeLetterAndColon(slash)) { + if (limit == 2L) { + result.write(this, 3L) // Absolute on a named volume, like `C:\`. + } else { + result.write(this, 2L) // Relative to the named volume, like `C:`. + } + } + } + + val absolute = result.size > 0 + + val canonicalParts = mutableListOf<ByteString>() + while (!exhausted()) { + val limit = indexOfElement(ANY_SLASH) + + val part: ByteString + if (limit == -1L) { + part = readByteString() + } else { + part = readByteString(limit) + readByte() + } + + if (part == DOT_DOT) { + if (!absolute && (canonicalParts.isEmpty() || canonicalParts.last() == DOT_DOT)) { + canonicalParts.add(part) // '..' doesn't pop '..' for relative paths. + } else { + canonicalParts.removeLastOrNull() + } + } else if (part != DOT && part != ByteString.EMPTY) { + canonicalParts.add(part) + } + } + + for (i in 0 until canonicalParts.size) { + if (i > 0) result.write(slash) + result.write(canonicalParts[i]) + } + if (result.size == 0L) { + result.write(DOT) + } + + return Path(slash, result.readByteString()) +} + +private fun String.toSlash(): ByteString { + return when (this) { + "/" -> SLASH + "\\" -> BACKSLASH + else -> throw IllegalArgumentException("not a directory separator: $this") + } +} + +private fun Byte.toSlash(): ByteString { + return when (toInt()) { + '/'.toInt() -> SLASH + '\\'.toInt() -> BACKSLASH + else -> throw IllegalArgumentException("not a directory separator: $this") + } +} + +private fun Buffer.startsWithVolumeLetterAndColon(slash: ByteString): Boolean { + if (slash != BACKSLASH) return false + if (size < 2) return false + if (get(1) != ':'.toByte()) return false + val b = get(0).toChar() + return b in 'a'..'z' || b in 'A'..'Z' +} diff --git a/okio/src/commonTest/kotlin/okio/AbstractFileSystemTest.kt b/okio/src/commonTest/kotlin/okio/AbstractFileSystemTest.kt new file mode 100644 index 00000000..669138f5 --- /dev/null +++ b/okio/src/commonTest/kotlin/okio/AbstractFileSystemTest.kt @@ -0,0 +1,670 @@ +/* + * Copyright (C) 2020 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 kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import okio.ByteString.Companion.toByteString +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem +import kotlin.random.Random +import kotlin.test.BeforeTest +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.seconds + +/** This test assumes that okio-files/ is the current working directory when executed. */ +@ExperimentalTime +@ExperimentalFileSystem +abstract class AbstractFileSystemTest( + val clock: Clock, + val fileSystem: FileSystem, + val windowsLimitations: Boolean, + temporaryDirectory: Path +) { + val base: Path = temporaryDirectory / "${this::class.simpleName}-${randomToken()}" + private val isJs = fileSystem::class.simpleName?.startsWith("NodeJs") ?: false + + @BeforeTest + fun setUp() { + fileSystem.createDirectory(base) + } + + @Test + fun canonicalizeDotReturnsCurrentWorkingDirectory() { + if (fileSystem is FakeFileSystem || fileSystem is ForwardingFileSystem) return + val cwd = fileSystem.canonicalize(".".toPath()) + val cwdString = cwd.toString() + assertTrue(cwdString) { + cwdString.endsWith("okio${Path.DIRECTORY_SEPARATOR}okio") || + cwdString.endsWith("${Path.DIRECTORY_SEPARATOR}okio-parent-okio-test") || // JS + cwdString.contains("/CoreSimulator/Devices/") || // iOS simulator. + cwdString == "/" // Android emulator. + } + } + + @Test + fun canonicalizeNoSuchFile() { + assertFailsWith<FileNotFoundException> { + fileSystem.canonicalize(base / "no-such-file") + } + } + + @Test + fun list() { + val target = base / "list" + target.writeUtf8("hello, world!") + val entries = fileSystem.list(base) + assertTrue(entries.toString()) { target in entries } + } + + @Test + fun listResultsAreSorted() { + val fileA = base / "a" + val fileB = base / "b" + val fileC = base / "c" + val fileD = base / "d" + + // Create files in a different order than the sorted order, so a file system that returns files + // in creation-order or reverse-creation order won't pass by accident. + fileD.writeUtf8("fileD") + fileB.writeUtf8("fileB") + fileC.writeUtf8("fileC") + fileA.writeUtf8("fileA") + + val entries = fileSystem.list(base) + assertEquals(entries, listOf(fileA, fileB, fileC, fileD)) + } + + @Test + fun listNoSuchDirectory() { + assertFailsWith<FileNotFoundException> { + fileSystem.list(base / "no-such-directory") + } + } + + @Test + fun listFile() { + val target = base / "list" + target.writeUtf8("hello, world!") + assertFailsWith<IOException> { + fileSystem.list(target) + } + } + + @Test + fun fileSourceNoSuchDirectory() { + assertFailsWith<FileNotFoundException> { + fileSystem.source(base / "no-such-directory" / "file") + } + } + + @Test + fun fileSource() { + val path = base / "file-source" + path.writeUtf8("hello, world!") + + val source = fileSystem.source(path) + val buffer = Buffer() + assertTrue(source.read(buffer, 100L) == 13L) + assertEquals(-1L, source.read(buffer, 100L)) + assertEquals("hello, world!", buffer.readUtf8()) + source.close() + } + + @Test + fun readPath() { + val path = base / "read-path" + val string = "hello, read with a Path" + path.writeUtf8(string) + + val result = fileSystem.read(path) { + assertEquals("hello", readUtf8(5)) + assertEquals(", read with ", readUtf8(12)) + assertEquals("a Path", readUtf8()) + return@read "success" + } + assertEquals("success", result) + } + + @Test + fun fileSink() { + val path = base / "file-sink" + val sink = fileSystem.sink(path) + val buffer = Buffer().writeUtf8("hello, world!") + sink.write(buffer, buffer.size) + sink.close() + assertTrue(path in fileSystem.list(base)) + assertEquals(0, buffer.size) + assertEquals("hello, world!", path.readUtf8()) + } + + @Test + fun writePath() { + val path = base / "write-path" + val content = fileSystem.write(path) { + val string = "hello, write with a Path" + writeUtf8(string) + return@write string + } + assertTrue(path in fileSystem.list(base)) + assertEquals(content, path.readUtf8()) + } + + @Test + fun appendingSinkAppendsToExistingFile() { + val path = base / "appending-sink-appends-to-existing-file" + path.writeUtf8("hello, world!\n") + val sink = fileSystem.appendingSink(path) + val buffer = Buffer().writeUtf8("this is added later!") + sink.write(buffer, buffer.size) + sink.close() + assertTrue(path in fileSystem.list(base)) + assertEquals("hello, world!\nthis is added later!", path.readUtf8()) + } + + @Test + fun appendingSinkDoesNotImpactExistingFile() { + val path = base / "appending-sink-does-not-impact-existing-file" + path.writeUtf8("hello, world!\n") + val sink = fileSystem.appendingSink(path) + assertEquals("hello, world!\n", path.readUtf8()) + sink.close() + assertEquals("hello, world!\n", path.readUtf8()) + } + + @Test + fun appendingSinkCreatesNewFile() { + val path = base / "appending-sink-creates-new-file" + val sink = fileSystem.appendingSink(path) + val buffer = Buffer().writeUtf8("this is all there is!") + sink.write(buffer, buffer.size) + sink.close() + assertTrue(path in fileSystem.list(base)) + assertEquals("this is all there is!", path.readUtf8()) + } + + @Test + fun fileSinkFlush() { + val path = base / "file-sink" + val sink = fileSystem.sink(path) + + val buffer = Buffer().writeUtf8("hello,") + sink.write(buffer, buffer.size) + sink.flush() + assertEquals("hello,", path.readUtf8()) + + buffer.writeUtf8(" world!") + sink.write(buffer, buffer.size) + sink.close() + assertEquals("hello, world!", path.readUtf8()) + } + + @Test + fun fileSinkNoSuchDirectory() { + assertFailsWith<FileNotFoundException> { + fileSystem.sink(base / "no-such-directory" / "file") + } + } + + @Test + fun createDirectory() { + val path = base / "create-directory" + fileSystem.createDirectory(path) + assertTrue(path in fileSystem.list(base)) + } + + @Test + fun createDirectoryAlreadyExists() { + val path = base / "already-exists" + fileSystem.createDirectory(path) + assertFailsWith<IOException> { + fileSystem.createDirectory(path) + } + } + + @Test + fun createDirectoryParentDirectoryDoesNotExist() { + val path = base / "no-such-directory" / "created" + assertFailsWith<IOException> { + fileSystem.createDirectory(path) + } + } + + @Test + fun createDirectoriesSingle() { + val path = base / "create-directories-single" + fileSystem.createDirectories(path) + assertTrue(path in fileSystem.list(base)) + assertTrue(fileSystem.metadata(path).isDirectory) + } + + @Test + fun createDirectoriesAlreadyExists() { + val path = base / "already-exists" + fileSystem.createDirectory(path) + fileSystem.createDirectories(path) + assertTrue(fileSystem.metadata(path).isDirectory) + } + + @Test + fun createDirectoriesParentDirectoryDoesNotExist() { + fileSystem.createDirectories(base / "a" / "b" / "c") + assertTrue(base / "a" in fileSystem.list(base)) + assertTrue(base / "a" / "b" in fileSystem.list(base / "a")) + assertTrue(base / "a" / "b" / "c" in fileSystem.list(base / "a" / "b")) + assertTrue(fileSystem.metadata(base / "a" / "b" / "c").isDirectory) + } + + @Test + fun createDirectoriesParentIsFile() { + val file = base / "simple-file" + file.writeUtf8("just a file") + assertFailsWith<IOException> { + fileSystem.createDirectories(file / "child") + } + } + + @Test + fun atomicMoveFile() { + val source = base / "source" + source.writeUtf8("hello, world!") + val target = base / "target" + fileSystem.atomicMove(source, target) + assertEquals("hello, world!", target.readUtf8()) + assertTrue(source !in fileSystem.list(base)) + assertTrue(target in fileSystem.list(base)) + } + + @Test + fun atomicMoveDirectory() { + val source = base / "source" + fileSystem.createDirectory(source) + val target = base / "target" + fileSystem.atomicMove(source, target) + assertTrue(source !in fileSystem.list(base)) + assertTrue(target in fileSystem.list(base)) + } + + @Test + fun atomicMoveSourceIsTarget() { + val source = base / "source" + source.writeUtf8("hello, world!") + fileSystem.atomicMove(source, source) + assertEquals("hello, world!", source.readUtf8()) + assertTrue(source in fileSystem.list(base)) + } + + @Test + fun atomicMoveClobberExistingFile() { + val source = base / "source" + source.writeUtf8("hello, world!") + val target = base / "target" + target.writeUtf8("this file will be clobbered!") + fileSystem.atomicMove(source, target) + assertEquals("hello, world!", target.readUtf8()) + assertTrue(source !in fileSystem.list(base)) + assertTrue(target in fileSystem.list(base)) + } + + @Test + fun atomicMoveSourceDoesNotExist() { + val source = base / "source" + val target = base / "target" + assertFailsWith<FileNotFoundException> { + fileSystem.atomicMove(source, target) + } + } + + @Test + fun atomicMoveSourceIsFileAndTargetIsDirectory() { + val source = base / "source" + source.writeUtf8("hello, world!") + val target = base / "target" + fileSystem.createDirectory(target) + assertFailsWith<IOException> { + fileSystem.atomicMove(source, target) + } + } + + @Test + fun atomicMoveSourceIsDirectoryAndTargetIsFile() { + val source = base / "source" + fileSystem.createDirectory(source) + val target = base / "target" + target.writeUtf8("hello, world!") + expectIOExceptionOnEverythingButWindows { + fileSystem.atomicMove(source, target) + } + } + + @Test + fun copyFile() { + val source = base / "source" + source.writeUtf8("hello, world!") + val target = base / "target" + fileSystem.copy(source, target) + assertTrue(target in fileSystem.list(base)) + assertEquals("hello, world!", source.readUtf8()) + assertEquals("hello, world!", target.readUtf8()) + } + + @Test + fun copySourceDoesNotExist() { + val source = base / "source" + val target = base / "target" + assertFailsWith<FileNotFoundException> { + fileSystem.copy(source, target) + } + assertFalse(target in fileSystem.list(base)) + } + + @Test + fun copyTargetIsClobbered() { + val source = base / "source" + source.writeUtf8("hello, world!") + val target = base / "target" + target.writeUtf8("this file will be clobbered!") + fileSystem.copy(source, target) + assertTrue(target in fileSystem.list(base)) + assertEquals("hello, world!", target.readUtf8()) + } + + @Test + fun deleteFile() { + val path = base / "delete-file" + path.writeUtf8("delete me") + fileSystem.delete(path) + assertTrue(path !in fileSystem.list(base)) + } + + @Test + fun deleteEmptyDirectory() { + val path = base / "delete-empty-directory" + fileSystem.createDirectory(path) + fileSystem.delete(path) + assertTrue(path !in fileSystem.list(base)) + } + + @Test + fun deleteFailsOnNoSuchFile() { + val path = base / "no-such-file" + // TODO(jwilson): fix Windows to throw FileNotFoundException on deleting an absent file. + if (windowsLimitations) { + assertFailsWith<IOException> { + fileSystem.delete(path) + } + } else { + assertFailsWith<FileNotFoundException> { + fileSystem.delete(path) + } + } + } + + @Test + fun deleteFailsOnNonemptyDirectory() { + val path = base / "non-empty-directory" + fileSystem.createDirectory(path) + (path / "file.txt").writeUtf8("inside directory") + assertFailsWith<IOException> { + fileSystem.delete(path) + } + } + + @Test + fun deleteRecursivelyFile() { + val path = base / "delete-recursively-file" + path.writeUtf8("delete me") + fileSystem.deleteRecursively(path) + assertTrue(path !in fileSystem.list(base)) + } + + @Test + fun deleteRecursivelyEmptyDirectory() { + val path = base / "delete-recursively-empty-directory" + fileSystem.createDirectory(path) + fileSystem.deleteRecursively(path) + assertTrue(path !in fileSystem.list(base)) + } + + @Test + fun deleteRecursivelyFailsOnNoSuchFile() { + val path = base / "no-such-file" + assertFailsWith<FileNotFoundException> { + fileSystem.deleteRecursively(path) + } + } + + @Test + fun deleteRecursivelyNonemptyDirectory() { + val path = base / "delete-recursively-non-empty-directory" + fileSystem.createDirectory(path) + (path / "file.txt").writeUtf8("inside directory") + fileSystem.deleteRecursively(path) + assertTrue(path !in fileSystem.list(base)) + assertTrue((path / "file.txt") !in fileSystem.list(base)) + } + + @Test + fun deleteRecursivelyDeepHierarchy() { + fileSystem.createDirectory(base / "a") + fileSystem.createDirectory(base / "a" / "b") + fileSystem.createDirectory(base / "a" / "b" / "c") + (base / "a" / "b" / "c" / "d.txt").writeUtf8("inside deep hierarchy") + fileSystem.deleteRecursively(base / "a") + assertEquals(fileSystem.list(base), listOf()) + } + + @Test + fun fileMetadata() { + val minTime = clock.now().minFileSystemTime() + val path = base / "file-metadata" + path.writeUtf8("hello, world!") + val maxTime = clock.now().maxFileSystemTime() + + val metadata = fileSystem.metadata(path) + assertTrue(metadata.isRegularFile) + assertFalse(metadata.isDirectory) + assertEquals(13, metadata.size) + assertInRange(metadata.createdAt, minTime, maxTime) + assertInRange(metadata.lastModifiedAt, minTime, maxTime) + assertInRange(metadata.lastAccessedAt, minTime, maxTime) + } + + @Test + fun directoryMetadata() { + val minTime = clock.now().minFileSystemTime() + val path = base / "directory-metadata" + fileSystem.createDirectory(path) + val maxTime = clock.now().maxFileSystemTime() + + val metadata = fileSystem.metadata(path) + assertFalse(metadata.isRegularFile) + assertTrue(metadata.isDirectory) + // Note that the size check is omitted; we'd expect null but the JVM returns values like 64. + assertInRange(metadata.createdAt, minTime, maxTime) + assertInRange(metadata.lastModifiedAt, minTime, maxTime) + assertInRange(metadata.lastAccessedAt, minTime, maxTime) + } + + @Test + fun absentMetadataOrNull() { + val path = base / "no-such-file" + assertNull(fileSystem.metadataOrNull(path)) + } + + @Test + @Ignore + fun inaccessibleMetadata() { + // TODO(swankjesse): configure a test directory in CI that exists, but that this process doesn't + // have permission to read metadata of. Perhaps a file in another user's /home directory? + } + + @Test + fun absentMetadata() { + val path = base / "no-such-file" + assertFailsWith<FileNotFoundException> { + fileSystem.metadata(path) + } + } + + @Test + fun fileExists() { + val path = base / "file-exists" + assertFalse(fileSystem.exists(path)) + path.writeUtf8("hello, world!") + assertTrue(fileSystem.exists(path)) + } + + @Test + fun directoryExists() { + val path = base / "directory-exists" + assertFalse(fileSystem.exists(path)) + fileSystem.createDirectory(path) + assertTrue(fileSystem.exists(path)) + } + + @Test + fun deleteOpenForWritingFailsOnWindows() { + val file = base / "file.txt" + expectIOExceptionOnWindows(exceptJs = true) { + fileSystem.sink(file).use { + fileSystem.delete(file) + } + } + } + + @Test + fun deleteOpenForReadingFailsOnWindows() { + val file = base / "file.txt" + file.writeUtf8("abc") + expectIOExceptionOnWindows(exceptJs = true) { + fileSystem.source(file).use { + fileSystem.delete(file) + } + } + } + + @Test + fun renameSourceIsOpenFailsOnWindows() { + val from = base / "from.txt" + val to = base / "to.txt" + from.writeUtf8("source file") + to.writeUtf8("target file") + expectIOExceptionOnWindows(exceptJs = true) { + fileSystem.source(from).use { + fileSystem.atomicMove(from, to) + } + } + } + + @Test + fun renameTargetIsOpenFailsOnWindows() { + val from = base / "from.txt" + val to = base / "to.txt" + from.writeUtf8("source file") + to.writeUtf8("target file") + expectIOExceptionOnWindows { + fileSystem.source(to).use { + fileSystem.atomicMove(from, to) + } + } + } + + @Test + fun deleteContentsOfParentOfFileOpenForReadingFailsOnWindows() { + val parentA = (base / "a") + fileSystem.createDirectory(parentA) + val parentAB = parentA / "b" + fileSystem.createDirectory(parentAB) + val parentABC = parentAB / "c" + fileSystem.createDirectory(parentABC) + val file = parentABC / "file.txt" + file.writeUtf8("child file") + expectIOExceptionOnWindows { + fileSystem.source(file).use { + fileSystem.delete(file) + fileSystem.delete(parentABC) + fileSystem.delete(parentAB) + fileSystem.delete(parentA) + } + } + } + + private fun expectIOExceptionOnWindows(exceptJs: Boolean = false, block: () -> Unit) { + val expectCrash = windowsLimitations && (!isJs || !exceptJs) + try { + block() + assertFalse(expectCrash) + } catch (_: IOException) { + assertTrue(expectCrash) + } + } + + private fun expectIOExceptionOnEverythingButWindows(block: () -> Unit) { + try { + block() + assertTrue(windowsLimitations) + } catch (e: IOException) { + assertFalse(windowsLimitations) + } + } + + private fun randomToken() = Random.nextBytes(16).toByteString(0, 16).hex() + + fun Path.readUtf8(): String { + return fileSystem.source(this).buffer().use { + it.readUtf8() + } + } + + fun Path.writeUtf8(string: String) { + fileSystem.sink(this).buffer().use { + it.writeUtf8(string) + } + } + + /** + * Returns the earliest file system time that could be recorded for an event occurring at this + * instant. This truncates fractional seconds because most host file systems do not use precise + * timestamps for file metadata. + */ + private fun Instant.minFileSystemTime(): Instant { + return Instant.fromEpochSeconds(epochSeconds) + } + + /** + * Returns the latest file system time that could be recorded for an event occurring at this + * instant. This adds 2 seconds and truncates fractional seconds because file systems may defer + * assigning the timestamp. + * + * https://docs.microsoft.com/en-us/windows/win32/sysinfo/file-times + */ + private fun Instant.maxFileSystemTime(): Instant { + return Instant.fromEpochSeconds(plus(2.seconds).epochSeconds) + } + + private fun assertInRange(sampled: Instant?, minTime: Instant, maxTime: Instant) { + if (sampled == null) return + assertTrue("expected $sampled in $minTime..$maxTime") { sampled in minTime..maxTime } + } +} diff --git a/okio/src/commonTest/kotlin/okio/FakeFileSystemTest.kt b/okio/src/commonTest/kotlin/okio/FakeFileSystemTest.kt new file mode 100644 index 00000000..f57ecd64 --- /dev/null +++ b/okio/src/commonTest/kotlin/okio/FakeFileSystemTest.kt @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2020 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 okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.minutes + +@ExperimentalTime +@ExperimentalFileSystem +class FakeWindowsFileSystemTest : FakeFileSystemTest( + clock = FakeClock(), + windowsLimitations = true, + temporaryDirectory = "C:\\".toPath(), +) + +@ExperimentalTime +@ExperimentalFileSystem +class FakeUnixFileSystemTest : FakeFileSystemTest( + clock = FakeClock(), + windowsLimitations = false, + temporaryDirectory = "/".toPath(), +) + +@ExperimentalTime +@ExperimentalFileSystem +abstract class FakeFileSystemTest internal constructor( + clock: FakeClock, + windowsLimitations: Boolean, + temporaryDirectory: Path +) : AbstractFileSystemTest( + clock = clock, + fileSystem = FakeFileSystem(windowsLimitations, clock = clock), + windowsLimitations = windowsLimitations, + temporaryDirectory = temporaryDirectory +) { + private val fakeFileSystem: FakeFileSystem = fileSystem as FakeFileSystem + private val fakeClock: FakeClock = clock + + @Test + fun openPathsIncludesOpenSink() { + val openPath = base / "open-file" + val sink = fileSystem.sink(openPath) + assertEquals(openPath, fakeFileSystem.openPaths.single()) + sink.close() + assertTrue(fakeFileSystem.openPaths.isEmpty()) + } + + @Test + fun openPathsIncludesOpenSource() { + val openPath = base / "open-file" + openPath.writeUtf8("hello, world!") + assertTrue(fakeFileSystem.openPaths.isEmpty()) + val source = fileSystem.source(openPath) + assertEquals(openPath, fakeFileSystem.openPaths.single()) + source.close() + assertTrue(fakeFileSystem.openPaths.isEmpty()) + } + + @Test + fun openPathsIsOpenOrder() { + val fileA = base / "a" + val fileB = base / "b" + val fileC = base / "c" + val fileD = base / "d" + + assertEquals(fakeFileSystem.openPaths, listOf()) + val sinkD = fileSystem.sink(fileD) + assertEquals(fakeFileSystem.openPaths, listOf(fileD)) + val sinkB = fileSystem.sink(fileB) + assertEquals(fakeFileSystem.openPaths, listOf(fileD, fileB)) + val sinkC = fileSystem.sink(fileC) + assertEquals(fakeFileSystem.openPaths, listOf(fileD, fileB, fileC)) + val sinkA = fileSystem.sink(fileA) + assertEquals(fakeFileSystem.openPaths, listOf(fileD, fileB, fileC, fileA)) + val sinkB2 = fileSystem.sink(fileB) + assertEquals(fakeFileSystem.openPaths, listOf(fileD, fileB, fileC, fileA, fileB)) + sinkD.close() + assertEquals(fakeFileSystem.openPaths, listOf(fileB, fileC, fileA, fileB)) + sinkB2.close() + assertEquals(fakeFileSystem.openPaths, listOf(fileB, fileC, fileA)) + sinkB.close() + assertEquals(fakeFileSystem.openPaths, listOf(fileC, fileA)) + sinkC.close() + assertEquals(fakeFileSystem.openPaths, listOf(fileA)) + sinkA.close() + assertEquals(fakeFileSystem.openPaths, listOf()) + } + + @Test + fun allPathsIncludesFile() { + val file = base / "all-files-includes-file" + file.writeUtf8("hello, world!") + assertEquals(fakeFileSystem.allPaths, setOf(base, file)) + } + + @Test + fun allPathsIsSorted() { + val fileA = base / "a" + val fileB = base / "b" + val fileC = base / "c" + val fileD = base / "d" + + // Create files in a different order than the sorted order, so a file system that returns files + // in creation-order or reverse-creation order won't pass by accident. + fileD.writeUtf8("fileD") + fileB.writeUtf8("fileB") + fileC.writeUtf8("fileC") + fileA.writeUtf8("fileA") + + assertEquals(fakeFileSystem.allPaths.toList(), listOf(base, fileA, fileB, fileC, fileD)) + } + + @Test + fun allPathsIncludesDirectory() { + val dir = base / "all-files-includes-directory" + fileSystem.createDirectory(dir) + assertEquals(fakeFileSystem.allPaths, setOf(base, dir)) + } + + @Test + fun allPathsDoesNotIncludeDeletedFile() { + val file = base / "all-files-does-not-include-deleted-file" + file.writeUtf8("hello, world!") + fileSystem.delete(file) + assertEquals(fakeFileSystem.allPaths, setOf(base)) + } + + @Test + fun allPathsDoesNotIncludeDeletedOpenFile() { + if (windowsLimitations) return // Can't delete open files with Windows' limitations. + + val file = base / "all-files-does-not-include-deleted-open-file" + val sink = fileSystem.sink(file) + assertEquals(fakeFileSystem.allPaths, setOf(base, file)) + fileSystem.delete(file) + assertEquals(fakeFileSystem.allPaths, setOf(base)) + sink.close() + } + + @Test + fun fileLastAccessedTime() { + val path = base / "file-last-accessed-time" + + fakeClock.sleep(1.minutes) + path.writeUtf8("hello, world!") + val createdAt = clock.now() + + fakeClock.sleep(1.minutes) + path.writeUtf8("hello again!") + val modifiedAt = clock.now() + + fakeClock.sleep(1.minutes) + path.readUtf8() + val accessedAt = clock.now() + + val metadata = fileSystem.metadata(path) + assertEquals(createdAt, metadata.createdAt) + assertEquals(modifiedAt, metadata.lastModifiedAt) + assertEquals(accessedAt, metadata.lastAccessedAt) + } + + @Test + fun directoryLastAccessedTime() { + val path = base / "directory-last-accessed-time" + + fakeClock.sleep(1.minutes) + fileSystem.createDirectory(path) + val createdAt = clock.now() + + fakeClock.sleep(1.minutes) + (path / "child").writeUtf8("hello world!") + val modifiedAt = clock.now() + + fakeClock.sleep(1.minutes) + fileSystem.list(path) + val accessedAt = clock.now() + + val metadata = fileSystem.metadata(path) + assertEquals(createdAt, metadata.createdAt) + assertEquals(modifiedAt, metadata.lastModifiedAt) + assertEquals(accessedAt, metadata.lastAccessedAt) + } + + @Test + fun checkNoOpenFilesThrowsOnOpenSource() { + val path = base / "check-no-open-files-open-source" + path.writeUtf8("hello, world!") + val exception = fileSystem.source(path).use { source -> + assertFailsWith<IllegalStateException> { + fakeFileSystem.checkNoOpenFiles() + } + } + + assertEquals( + """ + |expected 0 open files, but found: + | $path + """.trimMargin(), + exception.message + ) + assertEquals("file opened for reading here", exception.cause?.message) + + // Now that the source is closed this is safe. + fakeFileSystem.checkNoOpenFiles() + } + + @Test + fun checkNoOpenFilesThrowsOnOpenSink() { + val path = base / "check-no-open-files-open-sink" + val exception = fileSystem.sink(path).use { source -> + assertFailsWith<IllegalStateException> { + fakeFileSystem.checkNoOpenFiles() + } + } + + assertEquals( + """ + |expected 0 open files, but found: + | $path + """.trimMargin(), + exception.message + ) + assertEquals("file opened for writing here", exception.cause?.message) + + // Now that the source is closed this is safe. + fakeFileSystem.checkNoOpenFiles() + } + + @Test + fun createDirectoriesForVolumeLetterRoot() { + val path = "X:\\".toPath() + fileSystem.createDirectories(path) + assertTrue(fileSystem.metadata(path).isDirectory) + } + + @Test + fun createDirectoriesForChildOfVolumeLetterRoot() { + val path = "X:\\path".toPath() + fileSystem.createDirectories(path) + assertTrue(fileSystem.metadata(path).isDirectory) + } + + @Test + fun createDirectoriesForUnixRoot() { + val path = "/".toPath() + fileSystem.createDirectories(path) + assertTrue(fileSystem.metadata(path).isDirectory) + } + + @Test + fun createDirectoriesForChildOfUnixRoot() { + val path = "/path".toPath() + fileSystem.createDirectories(path) + assertTrue(fileSystem.metadata(path).isDirectory) + } + + @Test + fun createDirectoriesForUncRoot() { + val path = "\\\\server".toPath() + fileSystem.createDirectories(path) + assertTrue(fileSystem.metadata(path).isDirectory) + } + + @Test + fun createDirectoriesForChildOfUncRoot() { + val path = "\\\\server\\project".toPath() + fileSystem.createDirectories(path) + assertTrue(fileSystem.metadata(path).isDirectory) + } + + @Test + fun workingDirectoryMustBeAbsolute() { + val exception = assertFailsWith<IllegalArgumentException> { + FakeFileSystem(workingDirectory = "some/relative/path".toPath()) + } + assertEquals("expected an absolute path but was some/relative/path", exception.message) + } + + @Test + fun metadataForRootsGeneratedOnDemand() { + assertTrue(fileSystem.metadata("X:\\".toPath()).isDirectory) + assertTrue(fileSystem.metadata("/".toPath()).isDirectory) + assertTrue(fileSystem.metadata("\\\\server".toPath()).isDirectory) + } +} diff --git a/okio/src/commonTest/kotlin/okio/ForwardingFileSystemTest.kt b/okio/src/commonTest/kotlin/okio/ForwardingFileSystemTest.kt new file mode 100644 index 00000000..2f9669d5 --- /dev/null +++ b/okio/src/commonTest/kotlin/okio/ForwardingFileSystemTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2020 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 kotlinx.datetime.Clock +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime + +@ExperimentalTime +@ExperimentalFileSystem +class ForwardingFileSystemTest : AbstractFileSystemTest( + clock = Clock.System, + fileSystem = object : ForwardingFileSystem(FakeFileSystem()) {}, + windowsLimitations = false, + temporaryDirectory = "/".toPath() +) { + @Test + fun pathBlocking() { + val forwardingFileSystem = object : ForwardingFileSystem(fileSystem) { + override fun delete(path: Path) { + throw IOException("synthetic failure!") + } + + override fun onPathParameter(path: Path, functionName: String, parameterName: String): Path { + if (path.name.contains("blocked")) throw IOException("blocked path!") + return path + } + } + + forwardingFileSystem.createDirectory(base / "okay") + assertFailsWith<IOException> { + forwardingFileSystem.createDirectory(base / "blocked") + } + } + + @Test + fun operationBlocking() { + val forwardingFileSystem = object : ForwardingFileSystem(fileSystem) { + override fun onPathParameter(path: Path, functionName: String, parameterName: String): Path { + if (functionName == "delete") throw IOException("blocked operation!") + return path + } + } + + forwardingFileSystem.createDirectory(base / "operation-blocking") + assertFailsWith<IOException> { + forwardingFileSystem.delete(base / "operation-blocking") + } + } + + @Test + fun pathMapping() { + val prefix = "/mapped" + val source = base / "source" + val mappedSource = (prefix + source).toPath() + val target = base / "target" + val mappedTarget = (prefix + target).toPath() + + source.writeUtf8("hello, world!") + + val forwardingFileSystem = object : ForwardingFileSystem(fileSystem) { + override fun onPathParameter(path: Path, functionName: String, parameterName: String): Path { + return path.toString().removePrefix(prefix).toPath() + } + + override fun onPathResult(path: Path, functionName: String): Path { + return (prefix + path).toPath() + } + } + + forwardingFileSystem.copy(mappedSource, mappedTarget) + assertTrue(target in fileSystem.list(base)) + assertTrue(mappedTarget in forwardingFileSystem.list(base)) + assertEquals("hello, world!", source.readUtf8()) + assertEquals("hello, world!", target.readUtf8()) + } + + /** + * Path mapping might impact the sort order. Confirm that list() returns elements in sorted order + * even if that order is different in the delegate file system. + */ + @Test + fun pathMappingImpactedBySorting() { + val az = base / "az" + val by = base / "by" + val cx = base / "cx" + az.writeUtf8("az") + by.writeUtf8("by") + cx.writeUtf8("cx") + + val forwardingFileSystem = object : ForwardingFileSystem(fileSystem) { + override fun onPathResult(path: Path, functionName: String): Path { + return path.parent!! / path.name.reversed() + } + } + + assertEquals(fileSystem.list(base), listOf(base / "az", base / "by", base / "cx")) + assertEquals(forwardingFileSystem.list(base), listOf(base / "xc", base / "yb", base / "za")) + } + + @Test + fun copyIsNotForwarded() { + val log = mutableListOf<String>() + + val delegate = object : ForwardingFileSystem(fileSystem) { + override fun copy(source: Path, target: Path) { + throw AssertionError("unexpected call to copy()") + } + } + + val forwardingFileSystem = object : ForwardingFileSystem(delegate) { + override fun onPathParameter(path: Path, functionName: String, parameterName: String): Path { + log += "$functionName($parameterName=$path)" + return path + } + } + + val source = base / "source" + source.writeUtf8("hello, world!") + val target = base / "target" + forwardingFileSystem.copy(source, target) + assertTrue(target in fileSystem.list(base)) + assertEquals("hello, world!", source.readUtf8()) + assertEquals("hello, world!", target.readUtf8()) + + assertEquals(log, listOf("source(file=$source)", "sink(file=$target)")) + } +} diff --git a/okio/src/commonTest/kotlin/okio/PathTest.kt b/okio/src/commonTest/kotlin/okio/PathTest.kt new file mode 100644 index 00000000..92754a78 --- /dev/null +++ b/okio/src/commonTest/kotlin/okio/PathTest.kt @@ -0,0 +1,365 @@ +/* + * Copyright (C) 2020 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 okio.Path.Companion.toPath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@ExperimentalFileSystem +class PathTest { + @Test + fun unixRoot() { + val path = "/".toPath("/") + assertEquals("/", path.toString()) + assertNull(path.parent) + assertNull(path.volumeLetter) + assertEquals("", path.name) + assertTrue(path.isAbsolute) + assertTrue(path.isRoot) + } + + @Test + fun unixAbsolutePath() { + val path = "/home/jesse/todo.txt".toPath("/") + assertEquals("/home/jesse/todo.txt", path.toString()) + assertEquals("/home/jesse".toPath("/"), path.parent) + assertNull(path.volumeLetter) + assertEquals("todo.txt", path.name) + assertTrue(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun unixRelativePath() { + val path = "project/todo.txt".toPath("/") + assertEquals("project/todo.txt", path.toString()) + assertEquals("project".toPath("/"), path.parent) + assertNull(path.volumeLetter) + assertEquals("todo.txt", path.name) + assertFalse(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun unixRelativePathWithDots() { + val path = "../../project/todo.txt".toPath("/") + assertEquals("../../project/todo.txt", path.toString()) + assertEquals("../../project".toPath("/"), path.parent) + assertNull(path.volumeLetter) + assertEquals("todo.txt", path.name) + assertFalse(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun unixRelativeSeriesOfDotDots() { + val path = "../../..".toPath("/") + assertEquals("../../..", path.toString()) + assertNull(path.parent) + assertNull(path.volumeLetter) + assertEquals("..", path.name) + assertFalse(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun unixAbsoluteSeriesOfDotDots() { + val path = "/../../..".toPath("/") + assertEquals("/", path.toString()) + assertNull(path.parent) + assertNull(path.volumeLetter) + assertEquals("", path.name) + assertTrue(path.isAbsolute) + assertTrue(path.isRoot) + } + + @Test + fun unixAbsoluteSingleDot() { + val path = "/.".toPath("/") + assertEquals("/", path.toString()) + assertNull(path.parent) + assertNull(path.volumeLetter) + assertEquals("", path.name) + assertTrue(path.isAbsolute) + assertTrue(path.isRoot) + } + + @Test + fun unixRelativeDoubleDots() { + val path = "..".toPath("/") + assertEquals("..", path.toString()) + assertNull(path.parent) + assertNull(path.volumeLetter) + assertEquals("..", path.name) + assertFalse(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun unixRelativeSingleDot() { + val path = ".".toPath("/") + assertEquals(".", path.toString()) + assertNull(path.parent) + assertNull(path.volumeLetter) + assertEquals(".", path.name) + assertFalse(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun windowsVolumeLetter() { + val path = "C:\\".toPath("\\") + assertEquals("C:\\", path.toString()) + assertNull(path.parent) + assertEquals('C', path.volumeLetter) + assertEquals("", path.name) + assertTrue(path.isAbsolute) + assertTrue(path.isRoot) + } + + @Test + fun windowsAbsolutePathWithVolumeLetter() { + val path = "C:\\Windows\\notepad.exe".toPath("\\") + assertEquals("C:\\Windows\\notepad.exe", path.toString()) + assertEquals("C:\\Windows".toPath("\\"), path.parent) + assertEquals('C', path.volumeLetter) + assertEquals("notepad.exe", path.name) + assertTrue(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun windowsAbsolutePath() { + val path = "\\".toPath("\\") + assertEquals("\\", path.toString()) + assertEquals(null, path.parent) + assertNull(path.volumeLetter) + assertEquals("", path.name) + assertTrue(path.isAbsolute) + assertTrue(path.isRoot) + } + + @Test + fun windowsAbsolutePathWithoutVolumeLetter() { + val path = "\\Windows\\notepad.exe".toPath("\\") + assertEquals("\\Windows\\notepad.exe", path.toString()) + assertEquals("\\Windows".toPath("\\"), path.parent) + assertNull(path.volumeLetter) + assertEquals("notepad.exe", path.name) + assertTrue(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun windowsRelativePathWithVolumeLetter() { + val path = "C:Windows\\notepad.exe".toPath("\\") + assertEquals("C:Windows\\notepad.exe", path.toString()) + assertEquals("C:Windows".toPath("\\"), path.parent) + assertEquals('C', path.volumeLetter) + assertEquals("notepad.exe", path.name) + assertFalse(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun windowsVolumeLetterRelative() { + val path = "C:".toPath("\\") + assertEquals("C:", path.toString()) + assertNull(path.parent) + assertEquals('C', path.volumeLetter) + assertEquals("", path.name) + assertFalse(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun windowsRelativePath() { + val path = "Windows\\notepad.exe".toPath("\\") + assertEquals("Windows\\notepad.exe", path.toString()) + assertEquals("Windows".toPath("\\"), path.parent) + assertNull(path.volumeLetter) + assertEquals("notepad.exe", path.name) + assertFalse(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun windowsUncServer() { + val path = "\\\\server".toPath("\\") + assertEquals("\\\\server", path.toString()) + assertNull(path.parent) + assertNull(path.volumeLetter) + assertEquals("server", path.name) + assertTrue(path.isAbsolute) + assertTrue(path.isRoot) + } + + @Test + fun windowsUncAbsolutePath() { + val path = "\\\\server\\project\\notes.txt".toPath("\\") + assertEquals("\\\\server\\project\\notes.txt", path.toString()) + assertEquals("\\\\server\\project".toPath("\\"), path.parent) + assertNull(path.volumeLetter) + assertEquals("notes.txt", path.name) + assertTrue(path.isAbsolute) + assertFalse(path.isRoot) + } + + @Test + fun absolutePathTraversalWithDivOperator() { + val root = "/".toPath() + assertEquals("/home".toPath(), root / "home") + assertEquals("/home/jesse".toPath(), root / "home" / "jesse") + assertEquals("/home".toPath(), root / "home" / "jesse" / "..") + assertEquals("/home/jake".toPath(), root / "home" / "jesse" / ".." / "jake") + } + + @Test + fun relativePathTraversalWithDivOperator() { + val cwd = ".".toPath("/") + assertEquals("home".toPath("/"), cwd / "home") + assertEquals("home/jesse".toPath("/"), cwd / "home" / "jesse") + assertEquals("home".toPath("/"), cwd / "home" / "jesse" / "..") + assertEquals("home/jake".toPath("/"), cwd / "home" / "jesse" / ".." / "jake") + } + + @Test + fun relativePathTraversalWithDots() { + val cwd = ".".toPath("/") + assertEquals("..".toPath("/"), cwd / "..") + assertEquals("../..".toPath("/"), cwd / ".." / "..") + assertEquals("../../etc".toPath("/"), cwd / ".." / ".." / "etc") + assertEquals("../../etc/passwd".toPath("/"), cwd / ".." / ".." / "etc" / "passwd") + } + + @Test + fun pathTraversalBaseIgnoredIfChildIsAnAbsolutePath() { + assertEquals("/home".toPath(), "".toPath("/") / "/home") + assertEquals("/home".toPath(), "relative".toPath("/") / "/home") + assertEquals("/home".toPath(), "/base".toPath("/") / "/home") + assertEquals("/home".toPath(), "/".toPath("/") / "/home") + } + + @Test + fun stringToAbsolutePath() { + assertEquals("/", "/".toPath().toString()) + assertEquals("/a", "/a".toPath().toString()) + assertEquals("/a", "/a/".toPath().toString()) + assertEquals("/a/b/c", "/a/b/c".toPath().toString()) + assertEquals("/a/b/c", "/a/b/c/".toPath().toString()) + } + + @Test + fun stringToAbsolutePathWithTraversal() { + assertEquals("/", "/..".toPath().toString()) + assertEquals("/", "/../".toPath().toString()) + assertEquals("/", "/../..".toPath().toString()) + assertEquals("/", "/../../".toPath().toString()) + } + + @Test + fun stringToAbsolutePathWithEmptySegments() { + assertEquals("/", "//".toPath().toString()) + assertEquals("/a", "//a".toPath().toString()) + assertEquals("/a", "/a//".toPath().toString()) + assertEquals("/a", "//a//".toPath().toString()) + assertEquals("/a/b", "/a/b//".toPath().toString()) + } + + @Test + fun stringToAbsolutePathWithDots() { + assertEquals("/", "/./".toPath().toString()) + assertEquals("/a", "/./a".toPath().toString()) + assertEquals("/a", "/a/./".toPath().toString()) + assertEquals("/a", "/a//.".toPath().toString()) + assertEquals("/a", "/./a//".toPath().toString()) + assertEquals("/a", "/a/.".toPath().toString()) + assertEquals("/a", "//a/./".toPath().toString()) + assertEquals("/a", "//a/./.".toPath().toString()) + assertEquals("/a/b", "/a/./b/".toPath().toString()) + } + + @Test + fun stringToRelativePath() { + assertEquals(".", "".toPath().toString()) + assertEquals(".", ".".toPath().toString()) + assertEquals("a", "a/".toPath().toString()) + assertEquals("a/b", "a/b".toPath().toString()) + assertEquals("a/b", "a/b/".toPath().toString()) + assertEquals("a/b/c/d", "a/b/c/d".toPath().toString()) + assertEquals("a/b/c/d", "a/b/c/d/".toPath().toString()) + } + + @Test + fun stringToRelativePathWithTraversal() { + assertEquals("..", "..".toPath().toString()) + assertEquals("..", "../".toPath().toString()) + assertEquals(".", "a/..".toPath().toString()) + assertEquals(".", "a/../".toPath().toString()) + assertEquals("..", "a/../..".toPath().toString()) + assertEquals("..", "a/../../".toPath().toString()) + assertEquals("../..", "a/../../..".toPath().toString()) + assertEquals("../../b", "../../b".toPath().toString()) + assertEquals("../../b", "a/../../../b".toPath().toString()) + assertEquals("../../c", "a/../../../b/../c".toPath().toString()) + } + + @Test + fun stringToRelativePathWithEmptySegments() { + assertEquals("a", "a//".toPath().toString()) + assertEquals("a/b", "a//b".toPath().toString()) + assertEquals("a/b", "a/b//".toPath().toString()) + assertEquals("a/b", "a//b//".toPath().toString()) + assertEquals("a/b/c", "a/b/c//".toPath().toString()) + } + + @Test + fun stringToRelativePathWithDots() { + assertEquals(".", ".".toPath().toString()) + assertEquals(".", "./".toPath().toString()) + assertEquals(".", "././".toPath().toString()) + assertEquals(".", "././a/..".toPath().toString()) + assertEquals("a", "a/./".toPath().toString()) + assertEquals("a/b", "a/./b".toPath().toString()) + assertEquals("a/b", "a/b/./".toPath().toString()) + assertEquals("a/b", "a/b//.".toPath().toString()) + assertEquals("a/b", "a/./b//".toPath().toString()) + assertEquals("a/b", "a/b/.".toPath().toString()) + assertEquals("a/b", "a//b/./".toPath().toString()) + assertEquals("a/b", "a//b/./.".toPath().toString()) + assertEquals("a/b/c", "a/b/./c/".toPath().toString()) + } + + @Test + fun composingWindowsPath() { + assertEquals("C:\\Windows\\notepad.exe".toPath(), "C:\\".toPath() / "Windows" / "notepad.exe") + } + + @Test + fun windowsResolveAbsolutePath() { + assertEquals("\\Users".toPath(), "C:\\Windows".toPath() / "\\Users") + } + + @Test + fun windowsPathTraversalUp() { + assertEquals("C:\\z".toPath(), "C:\\x\\y\\..\\..\\..\\z".toPath()) + assertEquals("C:..\\z".toPath(), "C:x\\y\\..\\..\\..\\z".toPath()) + } +} diff --git a/okio/src/commonTest/kotlin/okio/SystemFileSystemTest.kt b/okio/src/commonTest/kotlin/okio/SystemFileSystemTest.kt new file mode 100644 index 00000000..df1d620a --- /dev/null +++ b/okio/src/commonTest/kotlin/okio/SystemFileSystemTest.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2020 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 kotlinx.datetime.Clock +import kotlin.time.ExperimentalTime + +@ExperimentalTime +@ExperimentalFileSystem +class SystemFileSystemTest : AbstractFileSystemTest( + clock = Clock.System, + fileSystem = FileSystem.SYSTEM, + windowsLimitations = Path.DIRECTORY_SEPARATOR == "\\", + temporaryDirectory = FileSystem.SYSTEM_TEMPORARY_DIRECTORY +) diff --git a/okio/src/commonTest/kotlin/okio/time.kt b/okio/src/commonTest/kotlin/okio/time.kt new file mode 100644 index 00000000..36fdf94a --- /dev/null +++ b/okio/src/commonTest/kotlin/okio/time.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2020 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 kotlinx.datetime.Instant + +@ExperimentalFileSystem +internal val FileMetadata.createdAt: Instant? + get() { + val createdAt = createdAtMillis ?: return null + return Instant.fromEpochMilliseconds(createdAt) + } + +@ExperimentalFileSystem +internal val FileMetadata.lastModifiedAt: Instant? + get() { + val lastModifiedAt = lastModifiedAtMillis ?: return null + return Instant.fromEpochMilliseconds(lastModifiedAt) + } + +@ExperimentalFileSystem +internal val FileMetadata.lastAccessedAt: Instant? + get() { + val lastAccessedAt = lastAccessedAtMillis ?: return null + return Instant.fromEpochMilliseconds(lastAccessedAt) + } diff --git a/okio/src/jsMain/kotlin/okio/-Platform.kt b/okio/src/jsMain/kotlin/okio/-Platform.kt new file mode 100644 index 00000000..25f93c3e --- /dev/null +++ b/okio/src/jsMain/kotlin/okio/-Platform.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2020 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 okio.Path.Companion.toPath + +@ExperimentalFileSystem +internal actual val PLATFORM_FILE_SYSTEM: FileSystem + get() = NodeJsFileSystem + +@ExperimentalFileSystem +internal actual val PLATFORM_TEMPORARY_DIRECTORY: Path + get() = tmpdir().toPath() + +internal actual val PLATFORM_DIRECTORY_SEPARATOR: String + get() { + // TODO(swankjesse): return path.path.sep instead, once it has @JsNonModule + return when (platform()) { + "win32" -> "\\" + else -> "/" + } + } diff --git a/okio/src/jsMain/kotlin/okio/FileSink.kt b/okio/src/jsMain/kotlin/okio/FileSink.kt new file mode 100644 index 00000000..c286cdfe --- /dev/null +++ b/okio/src/jsMain/kotlin/okio/FileSink.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2020 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 org.khronos.webgl.Uint8Array + +internal class FileSink( + private val fd: Number +) : Sink { + private var closed = false + + override fun write(source: Buffer, byteCount: Long) { + require(byteCount >= 0L) { "byteCount < 0: $byteCount" } + require(source.size >= byteCount) { "source.size=${source.size} < byteCount=$byteCount" } + check(!closed) { "closed" } + + val data = Uint8Array(byteCount.toInt()) + data.set(source.readByteArray().toTypedArray()) + val writtenByteCount = writeSync(fd, data) + if (writtenByteCount.toLong() != byteCount) { + throw IOException("expected $byteCount but was $writtenByteCount") + } + } + + override fun flush() { + } + + override fun timeout(): Timeout { + return Timeout.NONE + } + + override fun close() { + if (closed) return + closed = true + closeSync(fd) + } +} diff --git a/okio/src/jsMain/kotlin/okio/FileSource.kt b/okio/src/jsMain/kotlin/okio/FileSource.kt new file mode 100644 index 00000000..1b4f496f --- /dev/null +++ b/okio/src/jsMain/kotlin/okio/FileSource.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2020 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 org.khronos.webgl.Uint8Array +import org.khronos.webgl.get + +internal class FileSource( + private val fd: Number +) : Source { + var closed = false + + override fun read(sink: Buffer, byteCount: Long): Long { + require(byteCount >= 0L) { "byteCount < 0: $byteCount" } + check(!closed) { "closed" } + + val data = Uint8Array(byteCount.toInt()) + val readByteCount = readSync( + fd = fd, + buffer = data, + length = byteCount, + offset = 0, + position = null + ).toInt() + + if (readByteCount == 0) return -1L + + for (i in 0 until readByteCount) { + sink.writeByte(data[i].toInt()) + } + + return readByteCount.toLong() + } + + override fun timeout(): Timeout = Timeout.NONE + + override fun close() { + if (closed) return + closed = true + closeSync(fd) + } +} diff --git a/okio/src/jsMain/kotlin/okio/NodeJsFileSystem.kt b/okio/src/jsMain/kotlin/okio/NodeJsFileSystem.kt new file mode 100644 index 00000000..18f344e1 --- /dev/null +++ b/okio/src/jsMain/kotlin/okio/NodeJsFileSystem.kt @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2020 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 okio.Path.Companion.toPath + +/** + * Use [Node.js APIs][node_fs] to implement the Okio file system interface. + * + * This class needs to make calls to some fs APIs that have multiple competing overloads. To + * unambiguously select an overload this passes `undefined` as the target type to some functions. + * + * [node_fs]: https://nodejs.org/dist/latest-v14.x/docs/api/fs.html + */ +@ExperimentalFileSystem +internal object NodeJsFileSystem : FileSystem() { + private var S_IFMT = 0xf000 // fs.constants.S_IFMT + private var S_IFREG = 0x8000 // fs.constants.S_IFREG + private var S_IFDIR = 0x4000 // fs.constants.S_IFDIR + + override fun canonicalize(path: Path): Path { + try { + val canonicalPath = realpathSync(path.toString()) + return canonicalPath.toString().toPath() + } catch (e: Throwable) { + throw e.toIOException() + } + } + + override fun metadataOrNull(path: Path): FileMetadata? { + val stat = try { + statSync(path.toString()) + } catch (e: Throwable) { + if (e.errorCode == "ENOENT") return null // "No such file or directory". + throw IOException(e.message) + } + return FileMetadata( + isRegularFile = stat.mode.toInt() and S_IFMT == S_IFREG, + isDirectory = stat.mode.toInt() and S_IFMT == S_IFDIR, + size = stat.size.toLong(), + createdAtMillis = stat.ctimeMs.toLong(), + lastModifiedAtMillis = stat.mtimeMs.toLong(), + lastAccessedAtMillis = stat.atimeMs.toLong() + ) + } + + /** + * Returns the error code on this `SystemError`. This uses `asDynamic()` because our JS bindings + * don't (yet) include the `SystemError` type. + * + * https://nodejs.org/dist/latest-v14.x/docs/api/errors.html#errors_class_systemerror + * https://nodejs.org/dist/latest-v14.x/docs/api/errors.html#errors_common_system_errors + */ + private val Throwable.errorCode + get() = asDynamic().code + + override fun list(dir: Path): List<Path> { + try { + val opendir = opendirSync(dir.toString()) + try { + val result = mutableListOf<Path>() + while (true) { + val dirent = opendir.readSync() ?: break + result += dir / dirent.name + } + result.sort() + return result + } finally { + opendir.closeSync() + } + } catch (e: Throwable) { + throw e.toIOException() + } + } + + override fun source(file: Path): Source { + try { + val fd = openSync(file.toString(), flags = "r") + return FileSource(fd) + } catch (e: Throwable) { + throw e.toIOException() + } + } + + override fun sink(file: Path): Sink { + try { + val fd = openSync(file.toString(), flags = "w") + return FileSink(fd) + } catch (e: Throwable) { + throw e.toIOException() + } + } + + override fun appendingSink(file: Path): Sink { + try { + val fd = openSync(file.toString(), flags = "a") + return FileSink(fd) + } catch (e: Throwable) { + throw e.toIOException() + } + } + + override fun createDirectory(dir: Path) { + try { + mkdirSync(dir.toString()) + } catch (e: Throwable) { + throw e.toIOException() + } + } + + override fun atomicMove(source: Path, target: Path) { + try { + renameSync(source.toString(), target.toString()) + } catch (e: Throwable) { + throw e.toIOException() + } + } + + /** + * We don't know if [path] is a file or a directory, but we don't (yet) have an API to delete + * either type. Just try each in sequence. + * + * TODO(jwilson): switch to fs.rmSync() when our minimum requirements are Node 14.14.0. + */ + override fun delete(path: Path) { + try { + unlinkSync(path.toString()) + return + } catch (e: Throwable) { + } + try { + rmdirSync(path.toString()) + } catch (e: Throwable) { + throw e.toIOException() + } + } + + private fun Throwable.toIOException(): IOException { + return when (errorCode) { + "ENOENT" -> FileNotFoundException(message) + else -> IOException(message) + } + } + + override fun toString() = "NodeJsSystemFileSystem" +} diff --git a/okio/src/jsMain/kotlin/okio/fs.kt b/okio/src/jsMain/kotlin/okio/fs.kt new file mode 100644 index 00000000..17f965ff --- /dev/null +++ b/okio/src/jsMain/kotlin/okio/fs.kt @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2020 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. + */ + +/** + * This class declares the subset of Node.js file system APIs that we need in Okio. + * + * + * Why not Dukat? + * -------------- + * + * This file does manually what ideally [Dukat] would do automatically. + * + * Dukat's generated stubs need awkward call sites to disambiguate overloads. For example, to call + * `mkdirSync()` we must specify an options parameter even though we just want the default: + * + * mkdirSync(dir.toString(), options = undefined as MakeDirectoryOptions?) + * + * By defining our own externals, we can omit the unwanted optional parameter from the declaration. + * This leads to nicer calling code! + * + * mkdirSync(dir.toString()) + * + * Dukat also gets the nullability wrong for `Dirent.readSync()`. + * + * + * Why not Kotlinx-nodejs? + * ----------------------- + * + * Even better than using Dukat directly would be to use the [official artifact][kotlinx_nodejs], + * itself generated with Dukat. We also don't use the official Node.js artifact for the reasons + * above, and also because it has an unstable API. + * + * + * Updating this file + * ------------------ + * + * To declare new external APIs, run Dukat to generate a full set of Node stubs. The easiest way to + * do this is to add an NPM dependency on `@types/node` in `jsMain`, like this: + * + * ``` + * jsMain { + * ... + * dependencies { + * implementation(npm('@types/node', '14.14.16', true)) + * ... + * } + * } + * ``` + * + * This will create a file with a full set of APIs to copy-paste from. + * + * ``` + * okio/build/externals/okio-parent-okio/src/fs.fs.module_node.kt + * ``` + * + * [Dukat]: https://github.com/kotlin/dukat + * [kotlinx_nodejs]: https://github.com/Kotlin/kotlinx-nodejs + */ +@file:JsModule("fs") +@file:JsNonModule +package okio + +import org.khronos.webgl.Uint8Array +import kotlin.js.Date + +internal external fun closeSync(fd: Number) + +internal external fun mkdirSync(path: String): String? + +internal external fun openSync(path: String, flags: String): Number + +internal external fun opendirSync(path: String): Dir + +internal external fun readSync(fd: Number, buffer: Uint8Array, offset: Number, length: Number, position: Number?): Number + +internal external fun realpathSync(path: String): dynamic /* String | Buffer */ + +internal external fun renameSync(oldPath: String, newPath: String) + +internal external fun rmdirSync(path: String) + +internal external fun statSync(path: String): Stats + +internal external fun unlinkSync(path: String) + +internal external fun writeSync(fd: Number, buffer: Uint8Array): Number + +internal open external class Dir { + open var path: String + open fun closeSync() + // Note that dukat's signature of readSync() returns a non-nullable Dirent; that's incorrect. + open fun readSync(): Dirent? +} + +internal open external class Dirent { + open fun isFile(): Boolean + open fun isDirectory(): Boolean + open fun isBlockDevice(): Boolean + open fun isCharacterDevice(): Boolean + open fun isSymbolicLink(): Boolean + open fun isFIFO(): Boolean + open fun isSocket(): Boolean + open var name: String +} + +internal external interface StatsBase<T> { + fun isFile(): Boolean + fun isDirectory(): Boolean + fun isBlockDevice(): Boolean + fun isCharacterDevice(): Boolean + fun isSymbolicLink(): Boolean + fun isFIFO(): Boolean + fun isSocket(): Boolean + var dev: T + var ino: T + var mode: T + var nlink: T + var uid: T + var gid: T + var rdev: T + var size: T + var blksize: T + var blocks: T + var atimeMs: T + var mtimeMs: T + var ctimeMs: T + var birthtimeMs: T + var atime: Date + var mtime: Date + var ctime: Date + var birthtime: Date +} + +internal open external class Stats : StatsBase<Number> { + override fun isFile(): Boolean + override fun isDirectory(): Boolean + override fun isBlockDevice(): Boolean + override fun isCharacterDevice(): Boolean + override fun isSymbolicLink(): Boolean + override fun isFIFO(): Boolean + override fun isSocket(): Boolean + override var dev: Number + override var ino: Number + override var mode: Number + override var nlink: Number + override var uid: Number + override var gid: Number + override var rdev: Number + override var size: Number + override var blksize: Number + override var blocks: Number + override var atimeMs: Number + override var mtimeMs: Number + override var ctimeMs: Number + override var birthtimeMs: Number + override var atime: Date + override var mtime: Date + override var ctime: Date + override var birthtime: Date +} diff --git a/okio/src/jsMain/kotlin/okio/os.kt b/okio/src/jsMain/kotlin/okio/os.kt new file mode 100644 index 00000000..d35cea5e --- /dev/null +++ b/okio/src/jsMain/kotlin/okio/os.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2020 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. + */ + +/** + * See `fs.kt` for information on what this file does and how to keep it up-to-date. + */ +@file:JsModule("os") +@file:JsNonModule +package okio + +internal external fun tmpdir(): String + +internal external fun platform(): String diff --git a/okio/src/jvmMain/kotlin/okio/-Platform.kt b/okio/src/jvmMain/kotlin/okio/-Platform.kt index 4edb3ce0..95e7ee52 100644 --- a/okio/src/jvmMain/kotlin/okio/-Platform.kt +++ b/okio/src/jvmMain/kotlin/okio/-Platform.kt @@ -17,6 +17,23 @@ @file:JvmName("-Platform") package okio +import okio.Path.Companion.toPath + +@ExperimentalFileSystem +internal actual val PLATFORM_FILE_SYSTEM: FileSystem + get() { + try { + Class.forName("java.nio.file.Files") + return NioSystemFileSystem() + } catch (e: ClassNotFoundException) { + return JvmSystemFileSystem() + } + } + +@ExperimentalFileSystem +internal actual val PLATFORM_TEMPORARY_DIRECTORY: Path + get() = System.getProperty("java.io.tmpdir").toPath() + internal actual fun ByteArray.toUtf8String(): String = String(this, Charsets.UTF_8) internal actual fun String.asUtf8ToByteArray(): ByteArray = toByteArray(Charsets.UTF_8) diff --git a/okio/src/jvmMain/kotlin/okio/JvmSystemFileSystem.kt b/okio/src/jvmMain/kotlin/okio/JvmSystemFileSystem.kt new file mode 100644 index 00000000..fdcfa64e --- /dev/null +++ b/okio/src/jvmMain/kotlin/okio/JvmSystemFileSystem.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2020 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 okio.Path.Companion.toOkioPath + +/** + * A file system that adapts `java.io`. + * + * This base class is used on Android API levels 15 (our minimum supported API) through 26 + * (the first release that includes java.nio.file). + */ +@ExperimentalFileSystem +internal open class JvmSystemFileSystem : FileSystem() { + override fun canonicalize(path: Path): Path { + val canonicalFile = path.toFile().canonicalFile + if (!canonicalFile.exists()) throw FileNotFoundException("no such file") + return canonicalFile.toOkioPath() + } + + override fun metadataOrNull(path: Path): FileMetadata? { + val file = path.toFile() + val isRegularFile = file.isFile + val isDirectory = file.isDirectory + val lastModifiedAtMillis = file.lastModified() + val size = file.length() + + if (!isRegularFile && + !isDirectory && + lastModifiedAtMillis == 0L && + size == 0L && + !file.exists() + ) { + return null + } + + return FileMetadata( + isRegularFile = isRegularFile, + isDirectory = isDirectory, + size = size, + createdAtMillis = null, + lastModifiedAtMillis = lastModifiedAtMillis, + lastAccessedAtMillis = null + ) + } + + override fun list(dir: Path): List<Path> { + val file = dir.toFile() + val entries = file.list() + if (entries == null) { + if (!file.exists()) throw FileNotFoundException("no such file $dir") + throw IOException("failed to list $dir") + } + val result = entries.mapTo(mutableListOf()) { dir / it } + result.sort() + return result + } + + override fun source(file: Path): Source { + return file.toFile().source() + } + + override fun sink(file: Path): Sink { + return file.toFile().sink() + } + + override fun appendingSink(file: Path): Sink { + return file.toFile().sink(append = true) + } + + override fun createDirectory(dir: Path) { + if (!dir.toFile().mkdir()) throw IOException("failed to create directory $dir") + } + + override fun atomicMove(source: Path, target: Path) { + val renamed = source.toFile().renameTo(target.toFile()) + if (!renamed) throw IOException("failed to move $source to $target") + } + + override fun delete(path: Path) { + val file = path.toFile() + val deleted = file.delete() + if (!deleted) { + if (!file.exists()) throw FileNotFoundException("no such file $path") + else throw IOException("failed to delete $path") + } + } + + override fun toString() = "JvmSystemFileSystem" +} diff --git a/okio/src/jvmMain/kotlin/okio/NioSystemFileSystem.kt b/okio/src/jvmMain/kotlin/okio/NioSystemFileSystem.kt new file mode 100644 index 00000000..45487729 --- /dev/null +++ b/okio/src/jvmMain/kotlin/okio/NioSystemFileSystem.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2020 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 org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.NoSuchFileException +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.FileTime + +/** + * Extends [JvmSystemFileSystem] for platforms that support `java.nio.files` first introduced in + * Java 7 and Android 8.0 (API level 26). + */ +@ExperimentalFileSystem +@IgnoreJRERequirement // Only used on platforms that support java.nio.file. +internal class NioSystemFileSystem : JvmSystemFileSystem() { + override fun metadataOrNull(path: Path): FileMetadata? { + val nioPath = path.toNioPath() + + val attributes = try { + Files.readAttributes( + nioPath, + BasicFileAttributes::class.java, + LinkOption.NOFOLLOW_LINKS + ) + } catch (_: NoSuchFileException) { + return null + } + + return FileMetadata( + isRegularFile = attributes.isRegularFile, + isDirectory = attributes.isDirectory, + size = attributes.size(), + createdAtMillis = attributes.creationTime()?.zeroToNull(), + lastModifiedAtMillis = attributes.lastModifiedTime()?.zeroToNull(), + lastAccessedAtMillis = attributes.lastAccessTime()?.zeroToNull() + ) + } + + /** + * Returns this time as a epoch millis. If this is 0L this returns null, because epoch time 0L is + * a special value that indicates the requested time was not available. + */ + private fun FileTime.zeroToNull(): Long? { + return toMillis().takeIf { it != 0L } + } + + override fun atomicMove(source: Path, target: Path) { + try { + Files.move(source.toNioPath(), target.toNioPath(), ATOMIC_MOVE, REPLACE_EXISTING) + } catch (e: NoSuchFileException) { + throw FileNotFoundException(e.message) + } catch (e: UnsupportedOperationException) { + throw IOException("atomic move not supported") + } + } + + override fun toString() = "NioSystemFileSystem" +} diff --git a/okio/src/jvmMain/kotlin/okio/Path.kt b/okio/src/jvmMain/kotlin/okio/Path.kt new file mode 100644 index 00000000..845ae908 --- /dev/null +++ b/okio/src/jvmMain/kotlin/okio/Path.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2020 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 okio.internal.commonCompareTo +import okio.internal.commonEquals +import okio.internal.commonHashCode +import okio.internal.commonIsAbsolute +import okio.internal.commonIsRelative +import okio.internal.commonIsRoot +import okio.internal.commonName +import okio.internal.commonNameBytes +import okio.internal.commonParent +import okio.internal.commonResolve +import okio.internal.commonToPath +import okio.internal.commonToString +import okio.internal.commonVolumeLetter +import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +import java.io.File +import java.nio.file.Paths +import java.nio.file.Path as NioPath + +@ExperimentalFileSystem +actual class Path internal actual constructor( + internal actual val slash: ByteString, + internal actual val bytes: ByteString +) : Comparable<Path> { + actual val isAbsolute: Boolean + get() = commonIsAbsolute() + + actual val isRelative: Boolean + get() = commonIsRelative() + + @get:JvmName("volumeLetter") + actual val volumeLetter: Char? + get() = commonVolumeLetter() + + @get:JvmName("nameBytes") + actual val nameBytes: ByteString + get() = commonNameBytes() + + @get:JvmName("name") + actual val name: String + get() = commonName() + + @get:JvmName("parent") + actual val parent: Path? + get() = commonParent() + + actual val isRoot: Boolean + get() = commonIsRoot() + + @JvmName("resolve") + actual operator fun div(child: String): Path = commonResolve(child) + + @JvmName("resolve") + actual operator fun div(child: Path): Path = commonResolve(child) + + fun toFile(): File = File(toString()) + + @IgnoreJRERequirement // Can only be invoked on platforms that have java.nio.file. + fun toNioPath(): NioPath = Paths.get(toString()) + + actual override fun compareTo(other: Path): Int = commonCompareTo(other) + + actual override fun equals(other: Any?): Boolean = commonEquals(other) + + actual override fun hashCode() = commonHashCode() + + actual override fun toString() = commonToString() + + actual companion object { + /** + * Either `/` (on UNIX-like systems including Android, iOS, and Linux) or `\` (on Windows + * systems). + */ + @JvmField + actual val DIRECTORY_SEPARATOR: String = File.separator + + @JvmName("get") @JvmStatic + actual fun String.toPath(): Path = commonToPath() + + @JvmName("get") @JvmStatic + actual fun String.toPath(directorySeparator: String?): Path = commonToPath(directorySeparator) + + @JvmName("get") @JvmStatic + fun File.toOkioPath(): Path = toString().toPath() + + @JvmName("get") @JvmStatic + @IgnoreJRERequirement // Can only be invoked on platforms that have java.nio.file. + fun NioPath.toOkioPath(): Path = toString().toPath() + } +} diff --git a/okio/src/jvmTest/java/okio/FileSystemJavaTest.java b/okio/src/jvmTest/java/okio/FileSystemJavaTest.java new file mode 100644 index 00000000..0eab35bd --- /dev/null +++ b/okio/src/jvmTest/java/okio/FileSystemJavaTest.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2020 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 java.io.File; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import okio.fakefilesystem.FakeFileSystem; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public final class FileSystemJavaTest { + @Test + public void pathApi() { + Path path = Path.get("/home/jesse/todo.txt"); + + assertThat(Path.get("/home/jesse").resolve("todo.txt")).isEqualTo(path); + assertThat(Path.get("/home/jesse/todo.txt", "/")).isEqualTo(path); + assertThat(path.isAbsolute()).isTrue(); + assertThat(path.isRelative()).isFalse(); + assertThat(path.isRoot()).isFalse(); + assertThat(path.name()).isEqualTo("todo.txt"); + assertThat(path.nameBytes()).isEqualTo(ByteString.encodeUtf8("todo.txt")); + assertThat(path.parent()).isEqualTo(Path.get("/home/jesse")); + assertThat(path.volumeLetter()).isNull(); + } + + @Test + public void directorySeparator() { + assertThat(Path.DIRECTORY_SEPARATOR).isIn("/", "\\"); + } + + /** Like the same test in JvmTest, but this is using the Java APIs. */ + @Test + public void javaIoFileToOkioPath() { + String string = "/foo/bar/baz".replace("/", Path.DIRECTORY_SEPARATOR); + File javaIoFile = new File(string); + Path okioPath = Path.get(string); + assertThat(Path.get(javaIoFile)).isEqualTo(okioPath); + assertThat(okioPath.toFile()).isEqualTo(javaIoFile); + } + + /** Like the same test in JvmTest, but this is using the Java APIs. */ + @Test + public void nioPathToOkioPath() { + String string = "/foo/bar/baz".replace("/", okio.Path.DIRECTORY_SEPARATOR); + java.nio.file.Path nioPath = Paths.get(string); + Path okioPath = Path.get(string); + assertThat(Path.get(nioPath)).isEqualTo(okioPath); + assertThat((Object) okioPath.toNioPath()).isEqualTo(nioPath); + } + + @Test + public void fileSystemApi() throws IOException { + assertThat(FileSystem.SYSTEM.metadata(FileSystem.SYSTEM_TEMPORARY_DIRECTORY)).isNotNull(); + } + + @Test + public void fakeFileSystemApi() { + FakeFileSystem fakeFileSystem = new FakeFileSystem(); + assertThat(fakeFileSystem.clock).isNotNull(); + assertThat(fakeFileSystem.allPaths()).isEmpty(); + assertThat(fakeFileSystem.openPaths()).isEmpty(); + fakeFileSystem.checkNoOpenFiles(); + } + + @Test + public void forwardingFileSystemApi() throws IOException { + FakeFileSystem fakeFileSystem = new FakeFileSystem(); + final List<String> log = new ArrayList<>(); + ForwardingFileSystem forwardingFileSystem = new ForwardingFileSystem(fakeFileSystem) { + @Override public Path onPathParameter(Path path, String functionName, String parameterName) { + log.add(functionName + "(" + parameterName + "=" + path + ")"); + return path; + } + }; + forwardingFileSystem.metadataOrNull(Path.get("/")); + assertThat(log).containsExactly("metadataOrNull(path=/)"); + } +} diff --git a/okio/src/jvmTest/kotlin/okio/JvmTest.kt b/okio/src/jvmTest/kotlin/okio/JvmTest.kt new file mode 100644 index 00000000..f7114bcf --- /dev/null +++ b/okio/src/jvmTest/kotlin/okio/JvmTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2020 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 okio.Path.Companion.toOkioPath +import okio.Path.Companion.toPath +import org.assertj.core.api.Assertions.assertThat +import java.io.File +import java.nio.file.Paths +import kotlin.test.Test + +@ExperimentalFileSystem +class JvmTest { + @Test + fun baseDirectoryConsistentWithJavaIoFile() { + assertThat(FileSystem.SYSTEM.canonicalize(".".toPath()).toString()) + .isEqualTo(File("").canonicalFile.toString()) + } + + @Test + fun javaIoFileToOkioPath() { + val string = "/foo/bar/baz".replace("/", Path.DIRECTORY_SEPARATOR) + val javaIoFile = File(string) + val okioPath = string.toPath() + assertThat(javaIoFile.toOkioPath()).isEqualTo(okioPath) + assertThat(okioPath.toFile()).isEqualTo(javaIoFile) + } + + @Test + fun nioPathToOkioPath() { + val string = "/foo/bar/baz".replace("/", Path.DIRECTORY_SEPARATOR) + val nioPath = Paths.get(string) + val okioPath = string.toPath() + assertThat(nioPath.toOkioPath()).isEqualTo(okioPath) + assertThat(okioPath.toNioPath() as Any).isEqualTo(nioPath) + } +} diff --git a/okio/src/linuxX64Main/kotlin/okio/posixVariant.kt b/okio/src/linuxX64Main/kotlin/okio/posixVariant.kt new file mode 100644 index 00000000..89dd77d0 --- /dev/null +++ b/okio/src/linuxX64Main/kotlin/okio/posixVariant.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import platform.posix.ENOENT +import platform.posix.S_IFDIR +import platform.posix.S_IFMT +import platform.posix.S_IFREG +import platform.posix.errno +import platform.posix.stat + +@ExperimentalFileSystem +internal actual fun PosixFileSystem.variantMetadataOrNull(path: Path): FileMetadata? { + return memScoped { + val stat = alloc<stat>() + if (platform.posix.lstat(path.toString(), stat.ptr) != 0) { + if (errno == ENOENT) return null + throw errnoToIOException(errno) + } + return@memScoped FileMetadata( + isRegularFile = stat.st_mode.toInt() and S_IFMT == S_IFREG, + isDirectory = stat.st_mode.toInt() and S_IFMT == S_IFDIR, + size = stat.st_size, + createdAtMillis = stat.st_ctim.epochMillis, + lastModifiedAtMillis = stat.st_mtim.epochMillis, + lastAccessedAtMillis = stat.st_atim.epochMillis + ) + } +} diff --git a/okio/src/mingwX64Main/kotlin/okio/-Platform.kt b/okio/src/mingwX64Main/kotlin/okio/-Platform.kt new file mode 100644 index 00000000..98eb85f3 --- /dev/null +++ b/okio/src/mingwX64Main/kotlin/okio/-Platform.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.toKString +import okio.Path.Companion.toPath +import platform.posix.getenv + +@ExperimentalFileSystem +internal actual val PLATFORM_TEMPORARY_DIRECTORY: Path + get() { + // Windows' built-in APIs check the TEMP, TMP, and USERPROFILE environment variables in order. + // https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-gettemppatha?redirectedfrom=MSDN + val temp = getenv("TEMP") + if (temp != null) return temp.toKString().toPath() + + val tmp = getenv("TMP") + if (tmp != null) return tmp.toKString().toPath() + + val userProfile = getenv("USERPROFILE") + if (userProfile != null) return userProfile.toKString().toPath() + + return "\\Windows\\TEMP".toPath() + } diff --git a/okio/src/mingwX64Main/kotlin/okio/posixVariant.kt b/okio/src/mingwX64Main/kotlin/okio/posixVariant.kt new file mode 100644 index 00000000..e6167bd6 --- /dev/null +++ b/okio/src/mingwX64Main/kotlin/okio/posixVariant.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import okio.Path.Companion.toPath +import platform.posix.EACCES +import platform.posix.ENOENT +import platform.posix.PATH_MAX +import platform.posix.S_IFDIR +import platform.posix.S_IFMT +import platform.posix.S_IFREG +import platform.posix._fullpath +import platform.posix._stat64 +import platform.posix.errno +import platform.posix.free +import platform.posix.mkdir +import platform.posix.remove +import platform.posix.rmdir +import platform.windows.MOVEFILE_REPLACE_EXISTING +import platform.windows.MoveFileExA + +internal actual val PLATFORM_DIRECTORY_SEPARATOR = "\\" + +@ExperimentalFileSystem +internal actual fun PosixFileSystem.variantDelete(path: Path) { + val pathString = path.toString() + + if (remove(pathString) == 0) return + + // If remove failed with EACCES, it might be a directory. Try that. + if (errno == EACCES) { + if (rmdir(pathString) == 0) return + } + + throw errnoToIOException(EACCES) +} + +@ExperimentalFileSystem +internal actual fun PosixFileSystem.variantMkdir(dir: Path): Int { + return mkdir(dir.toString()) +} + +@ExperimentalFileSystem +internal actual fun PosixFileSystem.variantCanonicalize(path: Path): Path { + // Note that _fullpath() returns normally if the file doesn't exist. + val fullpath = _fullpath(null, path.toString(), PATH_MAX) + ?: throw errnoToIOException(errno) + try { + val pathString = Buffer().writeNullTerminated(fullpath).readUtf8() + if (platform.posix.access(pathString, 0) != 0 && errno == ENOENT) { + throw FileNotFoundException("no such file") + } + return pathString.toPath() + } finally { + free(fullpath) + } +} + +@ExperimentalFileSystem +internal actual fun PosixFileSystem.variantMetadataOrNull(path: Path): FileMetadata? { + return memScoped { + val stat = alloc<_stat64>() + if (_stat64(path.toString(), stat.ptr) != 0) { + if (errno == ENOENT) return null + throw errnoToIOException(errno) + } + return@memScoped FileMetadata( + isRegularFile = stat.st_mode.toInt() and S_IFMT == S_IFREG, + isDirectory = stat.st_mode.toInt() and S_IFMT == S_IFDIR, + size = stat.st_size, + createdAtMillis = stat.st_ctime * 1000L, + lastModifiedAtMillis = stat.st_mtime * 1000L, + lastAccessedAtMillis = stat.st_atime * 1000L + ) + } +} + +@ExperimentalFileSystem +internal actual fun PosixFileSystem.variantMove(source: Path, target: Path) { + if (MoveFileExA(source.toString(), target.toString(), MOVEFILE_REPLACE_EXISTING) == 0) { + throw lastErrorToIOException() + } +} diff --git a/okio/src/mingwX64Main/kotlin/okio/windows.kt b/okio/src/mingwX64Main/kotlin/okio/windows.kt new file mode 100644 index 00000000..a317f564 --- /dev/null +++ b/okio/src/mingwX64Main/kotlin/okio/windows.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.ByteVarOf +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.memScoped +import platform.windows.DWORD +import platform.windows.ERROR_FILE_NOT_FOUND +import platform.windows.ERROR_PATH_NOT_FOUND +import platform.windows.FORMAT_MESSAGE_FROM_SYSTEM +import platform.windows.FORMAT_MESSAGE_IGNORE_INSERTS +import platform.windows.FormatMessageA +import platform.windows.GetLastError +import platform.windows.LANG_NEUTRAL +import platform.windows.SUBLANG_DEFAULT + +internal fun lastErrorToIOException(): IOException { + val lastError = GetLastError() + return when (lastError.toInt()) { + ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND -> FileNotFoundException(lastErrorString(lastError)) + else -> IOException(lastErrorString(lastError)) + } +} + +internal fun lastErrorString(lastError: DWORD): String { + memScoped { + val messageMaxSize = 2048 + val message = allocArray<ByteVarOf<Byte>>(messageMaxSize) + FormatMessageA( + dwFlags = (FORMAT_MESSAGE_FROM_SYSTEM or FORMAT_MESSAGE_IGNORE_INSERTS).toUInt(), + lpSource = null, + dwMessageId = lastError, + dwLanguageId = (SUBLANG_DEFAULT * 1024 + LANG_NEUTRAL).toUInt(), // MAKELANGID macro. + lpBuffer = message, + nSize = messageMaxSize.toUInt(), + Arguments = null + ) + return Buffer().writeNullTerminated(message).readUtf8().trim() + } +} diff --git a/okio/src/nativeMain/kotlin/okio/-Platform.kt b/okio/src/nativeMain/kotlin/okio/-Platform.kt new file mode 100644 index 00000000..812b8482 --- /dev/null +++ b/okio/src/nativeMain/kotlin/okio/-Platform.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2020 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 + +@ExperimentalFileSystem +internal actual val PLATFORM_FILE_SYSTEM: FileSystem + get() = PosixFileSystem diff --git a/okio/src/nativeMain/kotlin/okio/FileSink.kt b/okio/src/nativeMain/kotlin/okio/FileSink.kt new file mode 100644 index 00000000..8b2c2bc1 --- /dev/null +++ b/okio/src/nativeMain/kotlin/okio/FileSink.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.CPointer +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import okio.Buffer.UnsafeCursor +import platform.posix.FILE +import platform.posix.errno +import platform.posix.fclose +import platform.posix.fflush + +/** Writes bytes to a file as a sink. */ +internal class FileSink( + private val file: CPointer<FILE> +) : Sink { + private val unsafeCursor = UnsafeCursor() + private var closed = false + + override fun write( + source: Buffer, + byteCount: Long + ) { + require(byteCount >= 0L) { "byteCount < 0: $byteCount" } + require(source.size >= byteCount) { "source.size=${source.size} < byteCount=$byteCount" } + check(!closed) { "closed" } + + var byteCount = byteCount + while (byteCount > 0) { + // Get the first segment, which we will read a contiguous range of bytes from. + val cursor = source.readUnsafe(unsafeCursor) + val segmentReadableByteCount = cursor.next() + val attemptCount = minOf(byteCount, segmentReadableByteCount.toLong()).toInt() + + // Copy bytes from that segment into the file. + val bytesWritten = cursor.data!!.usePinned { pinned -> + variantFwrite(pinned.addressOf(cursor.start), attemptCount.toUInt(), file).toLong() + } + + // Consume the bytes from the segment. + cursor.close() + source.skip(bytesWritten) + byteCount -= bytesWritten + + // If the write was shorter than expected, some I/O failed. + if (bytesWritten < attemptCount) { + throw errnoToIOException(errno) + } + } + } + + override fun flush() { + if (fflush(file) != 0) { + throw errnoToIOException(errno) + } + } + + override fun timeout(): Timeout = Timeout.NONE + + override fun close() { + if (closed) return + closed = true + if (fclose(file) != 0) { + throw errnoToIOException(errno) + } + } +} diff --git a/okio/src/nativeMain/kotlin/okio/FileSource.kt b/okio/src/nativeMain/kotlin/okio/FileSource.kt new file mode 100644 index 00000000..2b7416da --- /dev/null +++ b/okio/src/nativeMain/kotlin/okio/FileSource.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.CPointer +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import okio.Buffer.UnsafeCursor +import platform.posix.FILE +import platform.posix.errno +import platform.posix.fclose +import platform.posix.feof +import platform.posix.ferror + +/** Reads the bytes of a file as a source. */ +internal class FileSource( + private val file: CPointer<FILE> +) : Source { + private val unsafeCursor = UnsafeCursor() + private var closed = false + + override fun read( + sink: Buffer, + byteCount: Long + ): Long { + require(byteCount >= 0L) { "byteCount < 0: $byteCount" } + check(!closed) { "closed" } + val sinkInitialSize = sink.size + + // Request a writable segment in `sink`. We request at least 1024 bytes, unless the request is + // for smaller than that, in which case we request only that many bytes. + val cursor = sink.readAndWriteUnsafe(unsafeCursor) + val addedCapacityCount = cursor.expandBuffer(minByteCount = minOf(byteCount, 1024L).toInt()) + + // Now that we have a writable segment, figure out how many bytes to read. This is the smaller + // of the user's requested byte count, and the segment's writable capacity. + val attemptCount = minOf(byteCount, addedCapacityCount) + + // Copy bytes from the file to the segment. + val bytesRead = cursor.data!!.usePinned { pinned -> + variantFread(pinned.addressOf(cursor.start), attemptCount.toUInt(), file).toLong() + } + + // Remove new capacity that was added but not used. + cursor.resizeBuffer(sinkInitialSize + bytesRead) + cursor.close() + + return when { + bytesRead == attemptCount -> bytesRead + feof(file) != 0 -> if (bytesRead == 0L) -1L else bytesRead + ferror(file) != 0 -> throw errnoToIOException(errno) + else -> bytesRead + } + } + + override fun timeout(): Timeout = Timeout.NONE + + override fun close() { + if (closed) return + closed = true + fclose(file) + } +} diff --git a/okio/src/nativeMain/kotlin/okio/PosixFileSystem.kt b/okio/src/nativeMain/kotlin/okio/PosixFileSystem.kt new file mode 100644 index 00000000..bf8a71c3 --- /dev/null +++ b/okio/src/nativeMain/kotlin/okio/PosixFileSystem.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.CPointer +import kotlinx.cinterop.get +import okio.Path.Companion.toPath +import okio.internal.toPath +import platform.posix.DIR +import platform.posix.FILE +import platform.posix.closedir +import platform.posix.dirent +import platform.posix.errno +import platform.posix.fopen +import platform.posix.opendir +import platform.posix.readdir +import platform.posix.set_posix_errno + +@ExperimentalFileSystem +internal object PosixFileSystem : FileSystem() { + private val SELF_DIRECTORY_ENTRY = ".".toPath() + private val PARENT_DIRECTORY_ENTRY = "..".toPath() + + override fun canonicalize(path: Path) = variantCanonicalize(path) + + override fun metadataOrNull(path: Path): FileMetadata? { + return variantMetadataOrNull(path) + } + + override fun list(dir: Path): List<Path> { + val opendir: CPointer<DIR> = opendir(dir.toString()) + ?: throw errnoToIOException(errno) + + try { + val result = mutableListOf<Path>() + val buffer = Buffer() + + set_posix_errno(0) // If readdir() returns null it's either the end or an error. + while (true) { + val dirent: CPointer<dirent> = readdir(opendir) ?: break + val childPath = buffer.writeNullTerminated( + bytes = dirent[0].d_name + ).toPath() + + if (childPath == SELF_DIRECTORY_ENTRY || childPath == PARENT_DIRECTORY_ENTRY) { + continue // exclude '.' and '..' from the results. + } + + result += dir / childPath + } + + if (errno != 0) throw errnoToIOException(errno) + + result.sort() + return result + } finally { + closedir(opendir) // Ignore errno from closedir. + } + } + + override fun source(file: Path): Source { + val openFile: CPointer<FILE> = fopen(file.toString(), "r") + ?: throw errnoToIOException(errno) + return FileSource(openFile) + } + + override fun sink(file: Path): Sink { + val openFile: CPointer<FILE> = fopen(file.toString(), "w") + ?: throw errnoToIOException(errno) + return FileSink(openFile) + } + + override fun appendingSink(file: Path): Sink { + val openFile: CPointer<FILE> = fopen(file.toString(), "a") + ?: throw errnoToIOException(errno) + return FileSink(openFile) + } + + override fun createDirectory(dir: Path) { + val result = variantMkdir(dir) + if (result != 0) { + throw errnoToIOException(errno) + } + } + + override fun atomicMove( + source: Path, + target: Path + ) { + variantMove(source, target) + } + + override fun delete(path: Path) { + variantDelete(path) + } + + override fun toString() = "PosixSystemFileSystem" +} diff --git a/okio/src/nativeMain/kotlin/okio/cinterop.kt b/okio/src/nativeMain/kotlin/okio/cinterop.kt new file mode 100644 index 00000000..9c6850f0 --- /dev/null +++ b/okio/src/nativeMain/kotlin/okio/cinterop.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.ByteVarOf +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.get +import kotlinx.cinterop.set +import platform.posix.ENOENT +import platform.posix.strerror + +internal fun Buffer.writeNullTerminated(bytes: CPointer<ByteVarOf<Byte>>): Buffer = apply { + var pos = 0 + while (true) { + val byte = bytes[pos++].toInt() + if (byte == 0) { + break + } else { + writeByte(byte) + } + } +} + +internal fun Buffer.write( + source: CPointer<ByteVarOf<Byte>>, + offset: Int = 0, + byteCount: Int +): Buffer = apply { + for (i in offset until offset + byteCount) { + writeByte(source[i].toInt()) + } +} + +internal fun Buffer.read( + sink: CPointer<ByteVarOf<Byte>>, + offset: Int = 0, + byteCount: Int +): Buffer = apply { + for (i in offset until offset + byteCount) { + sink[i] = readByte() + } +} + +internal fun errnoToIOException(errno: Int): IOException { + val message = strerror(errno) + val messageString = if (message != null) { + Buffer().writeNullTerminated(message).readUtf8() + } else { + "errno: $errno" + } + return when (errno) { + ENOENT -> FileNotFoundException(messageString) + else -> IOException(messageString) + } +} diff --git a/okio/src/nativeMain/kotlin/okio/posixVariant.kt b/okio/src/nativeMain/kotlin/okio/posixVariant.kt new file mode 100644 index 00000000..4744d047 --- /dev/null +++ b/okio/src/nativeMain/kotlin/okio/posixVariant.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2020 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 + +@ExperimentalFileSystem +internal expect fun PosixFileSystem.variantDelete(path: Path) + +@ExperimentalFileSystem +internal expect fun PosixFileSystem.variantMkdir(dir: Path): Int + +@ExperimentalFileSystem +internal expect fun PosixFileSystem.variantCanonicalize(path: Path): Path + +@ExperimentalFileSystem +internal expect fun PosixFileSystem.variantMetadataOrNull(path: Path): FileMetadata? + +@ExperimentalFileSystem +internal expect fun PosixFileSystem.variantMove(source: Path, target: Path) diff --git a/okio/src/nativeMain/kotlin/okio/sizetVariant.kt b/okio/src/nativeMain/kotlin/okio/sizetVariant.kt new file mode 100644 index 00000000..b1c29c6d --- /dev/null +++ b/okio/src/nativeMain/kotlin/okio/sizetVariant.kt @@ -0,0 +1,33 @@ +/* +* Copyright (C) 2020 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 kotlinx.cinterop.ByteVar +import kotlinx.cinterop.ByteVarOf +import kotlinx.cinterop.CPointer +import platform.posix.FILE + +internal expect fun variantFread( + target: CPointer<ByteVarOf<Byte>>, + byteCount: UInt, + file: CPointer<FILE> +): UInt + +internal expect fun variantFwrite( + target: CPointer<ByteVar>, + byteCount: UInt, + file: CPointer<FILE> +): UInt diff --git a/okio/src/nonJvmMain/kotlin/okio/-Platform.kt b/okio/src/nonJvmMain/kotlin/okio/-Platform.kt index 90fcd36d..73ed2637 100644 --- a/okio/src/nonJvmMain/kotlin/okio/-Platform.kt +++ b/okio/src/nonJvmMain/kotlin/okio/-Platform.kt @@ -19,6 +19,8 @@ package okio import okio.internal.commonAsUtf8ToByteArray import okio.internal.commonToUtf8String +internal expect val PLATFORM_DIRECTORY_SEPARATOR: String + internal actual fun ByteArray.toUtf8String(): String = commonToUtf8String() internal actual fun String.asUtf8ToByteArray(): ByteArray = commonAsUtf8ToByteArray() diff --git a/okio/src/nonJvmMain/kotlin/okio/Path.kt b/okio/src/nonJvmMain/kotlin/okio/Path.kt new file mode 100644 index 00000000..31e679de --- /dev/null +++ b/okio/src/nonJvmMain/kotlin/okio/Path.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2020 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 okio.internal.commonCompareTo +import okio.internal.commonEquals +import okio.internal.commonHashCode +import okio.internal.commonIsAbsolute +import okio.internal.commonIsRelative +import okio.internal.commonIsRoot +import okio.internal.commonName +import okio.internal.commonNameBytes +import okio.internal.commonParent +import okio.internal.commonResolve +import okio.internal.commonToPath +import okio.internal.commonToString +import okio.internal.commonVolumeLetter + +@ExperimentalFileSystem +actual class Path internal actual constructor( + internal actual val slash: ByteString, + internal actual val bytes: ByteString +) : Comparable<Path> { + actual val isAbsolute: Boolean + get() = commonIsAbsolute() + + actual val isRelative: Boolean + get() = commonIsRelative() + + actual val volumeLetter: Char? + get() = commonVolumeLetter() + + actual val nameBytes: ByteString + get() = commonNameBytes() + + actual val name: String + get() = commonName() + + actual val parent: Path? + get() = commonParent() + + actual val isRoot: Boolean + get() = commonIsRoot() + + actual operator fun div(child: String): Path = commonResolve(child) + + actual operator fun div(child: Path): Path = commonResolve(child) + + actual override fun compareTo(other: Path): Int = commonCompareTo(other) + + actual override fun equals(other: Any?): Boolean = commonEquals(other) + + actual override fun hashCode() = commonHashCode() + + actual override fun toString() = commonToString() + + actual companion object { + actual val DIRECTORY_SEPARATOR: String = PLATFORM_DIRECTORY_SEPARATOR + + actual fun String.toPath(): Path = commonToPath() + + actual fun String.toPath(directorySeparator: String?): Path = commonToPath(directorySeparator) + } +} diff --git a/okio/src/sizet32Main/kotlin/okio/sizetVariant.kt b/okio/src/sizet32Main/kotlin/okio/sizetVariant.kt new file mode 100644 index 00000000..0495d62d --- /dev/null +++ b/okio/src/sizet32Main/kotlin/okio/sizetVariant.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.ByteVar +import kotlinx.cinterop.ByteVarOf +import kotlinx.cinterop.CPointer +import platform.posix.FILE +import platform.posix.fread +import platform.posix.fwrite + +internal actual fun variantFread( + target: CPointer<ByteVarOf<Byte>>, + byteCount: UInt, + file: CPointer<FILE> +): UInt { + return fread(target, 1, byteCount, file) +} + +internal actual fun variantFwrite( + target: CPointer<ByteVar>, + byteCount: UInt, + file: CPointer<FILE> +): UInt { + return fwrite(target, 1, byteCount, file) +} diff --git a/okio/src/sizet64Main/kotlin/okio/sizetVariant.kt b/okio/src/sizet64Main/kotlin/okio/sizetVariant.kt new file mode 100644 index 00000000..e024addf --- /dev/null +++ b/okio/src/sizet64Main/kotlin/okio/sizetVariant.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.ByteVar +import kotlinx.cinterop.ByteVarOf +import kotlinx.cinterop.CPointer +import platform.posix.FILE +import platform.posix.fread +import platform.posix.fwrite + +internal actual fun variantFread( + target: CPointer<ByteVarOf<Byte>>, + byteCount: UInt, + file: CPointer<FILE> +): UInt { + return fread(target, 1, byteCount.toULong(), file).toUInt() +} + +internal actual fun variantFwrite( + target: CPointer<ByteVar>, + byteCount: UInt, + file: CPointer<FILE> +): UInt { + return fwrite(target, 1, byteCount.toULong(), file).toUInt() +} diff --git a/okio/src/unixMain/kotlin/okio/-Platform.kt b/okio/src/unixMain/kotlin/okio/-Platform.kt new file mode 100644 index 00000000..07dcfda3 --- /dev/null +++ b/okio/src/unixMain/kotlin/okio/-Platform.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2020 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 kotlinx.cinterop.toKString +import okio.Path.Companion.toPath +import platform.posix.getenv + +@ExperimentalFileSystem +internal actual val PLATFORM_TEMPORARY_DIRECTORY: Path + get() { + val tmpdir = getenv("TMPDIR") + if (tmpdir != null) return tmpdir.toKString().toPath() + + return "/tmp".toPath() + } diff --git a/okio/src/unixMain/kotlin/okio/posixVariant.kt b/okio/src/unixMain/kotlin/okio/posixVariant.kt new file mode 100644 index 00000000..1c9ae416 --- /dev/null +++ b/okio/src/unixMain/kotlin/okio/posixVariant.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2020 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 okio.internal.toPath +import platform.posix.errno +import platform.posix.free +import platform.posix.mkdir +import platform.posix.realpath +import platform.posix.remove +import platform.posix.rename +import platform.posix.timespec + +internal actual val PLATFORM_DIRECTORY_SEPARATOR = "/" + +@ExperimentalFileSystem +internal actual fun PosixFileSystem.variantDelete(path: Path) { + val result = remove(path.toString()) + if (result != 0) { + throw errnoToIOException(errno) + } +} + +@ExperimentalFileSystem +internal actual fun PosixFileSystem.variantMkdir(dir: Path): Int { + return mkdir(dir.toString(), 0b111111111 /* octal 777 */) +} + +@ExperimentalFileSystem +internal actual fun PosixFileSystem.variantCanonicalize(path: Path): Path { + // Note that realpath() fails if the file doesn't exist. + val fullpath = realpath(path.toString(), null) + ?: throw errnoToIOException(errno) + try { + return Buffer().writeNullTerminated(fullpath).toPath() + } finally { + free(fullpath) + } +} + +@ExperimentalFileSystem +internal actual fun PosixFileSystem.variantMove( + source: Path, + target: Path +) { + val result = rename(source.toString(), target.toString()) + if (result != 0) { + throw errnoToIOException(errno) + } +} + +internal val timespec.epochMillis: Long + get() = tv_sec * 1000L + tv_sec / 1_000_000L diff --git a/settings.gradle b/settings.gradle index 97f9d9e9..08ea10d4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,7 @@ rootProject.name = 'okio-parent' include ':okio' +include ':okio-fakefilesystem' include ':okio:jvm:japicmp' include ':okio:jvm:jmh' include ':samples' |