aboutsummaryrefslogtreecommitdiff
path: root/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt
diff options
context:
space:
mode:
Diffstat (limited to 'okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt')
-rw-r--r--okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt679
1 files changed, 528 insertions, 151 deletions
diff --git a/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt b/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt
index fb426744..fb2bd655 100644
--- a/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt
+++ b/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt
@@ -15,11 +15,15 @@
*/
package okio.fakefilesystem
+import kotlin.jvm.JvmField
+import kotlin.jvm.JvmName
+import kotlin.reflect.KClass
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
+import okio.ArrayIndexOutOfBoundsException
import okio.Buffer
import okio.ByteString
-import okio.ExperimentalFileSystem
+import okio.FileHandle
import okio.FileMetadata
import okio.FileNotFoundException
import okio.FileSystem
@@ -28,11 +32,11 @@ 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
+import okio.fakefilesystem.FakeFileSystem.Element.Symlink
+import okio.fakefilesystem.FakeFileSystem.Operation.READ
+import okio.fakefilesystem.FakeFileSystem.Operation.WRITE
/**
* A fully in-memory file system useful for testing. It includes features to support writing
@@ -41,32 +45,82 @@ import kotlin.jvm.JvmName
* 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.
+ * Strict By Default
+ * -----------------
+ *
+ * These actions are not allowed and throw an [IOException] if attempted:
+ *
+ * * Moving a file that is currently open for reading or writing.
+ * * Deleting a file that is currently open for reading or writing.
+ * * Moving a file to a path that currently resolves to an empty directory.
+ * * Reading and writing the same file at the same time.
+ * * Opening a file for writing that is already open for writing.
+ *
+ * Programs that do not attempt any of the above operations should work fine on both UNIX and
+ * Windows systems. Relax these constraints individually or call [emulateWindows] or [emulateUnix];
+ * to apply the constraints of a particular operating system.
*/
-@ExperimentalFileSystem
class FakeFileSystem(
- private val windowsLimitations: Boolean = false,
- private val workingDirectory: Path = (if (windowsLimitations) "F:\\".toPath() else "/".toPath()),
-
@JvmField
- val clock: Clock = Clock.System
+ 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>()
+ /** File system roots. Each element is a Directory and is created on-demand. */
+ private val roots = mutableMapOf<Path, Directory>()
/** Files that are currently open and need to be closed to avoid resource leaks. */
private val openFiles = mutableListOf<OpenFile>()
/**
+ * An absolute path with this file system's current working directory. Relative paths will be
+ * resolved against this directory when they are used.
+ */
+ var workingDirectory: Path = "/".toPath()
+ set(value) {
+ require(value.isAbsolute) {
+ "expected an absolute path but was $value"
+ }
+ field = value
+ }
+
+ /**
+ * True to allow files to be moved even if they're currently open for read or write. UNIX file
+ * systems typically allow open files to be moved; Windows file systems do not.
+ */
+ var allowMovingOpenFiles = false
+
+ /**
+ * True to allow files to be deleted even if they're currently open for read or write. UNIX file
+ * systems typically allow open files to be deleted; Windows file systems do not.
+ */
+ var allowDeletingOpenFiles = false
+
+ /**
+ * True to allow the target of an [atomicMove] operation to be an empty directory. Windows file
+ * systems typically allow files to replace empty directories; UNIX file systems do not.
+ */
+ var allowClobberingEmptyDirectories = false
+
+ /**
+ * True to permit a file to have multiple [sinks][sink] open at the same time. Both Windows and
+ * UNIX file systems permit this but the result may be undefined.
+ */
+ var allowWritesWhileWriting = false
+
+ /**
+ * True to permit a file to have a [source] and [sink] open at the same time. Both Windows and
+ * UNIX file systems permit this but the result may be undefined.
+ */
+ var allowReadsWhileWriting = false
+
+ /**
+ * True to allow symlinks to be created. UNIX file systems typically allow symlinks; Windows file
+ * systems do not. Setting this to false after creating a symlink does not prevent that symlink
+ * from being returned or used.
+ */
+ var allowSymlinks = false
+
+ /**
* Canonical paths for every file and directory in this file system. This omits file system roots
* like `C:\` and `/`.
*/
@@ -74,9 +128,8 @@ class FakeFileSystem(
val allPaths: Set<Path>
get() {
val result = mutableListOf<Path>()
- for (path in elements.keys) {
- if (path.isRoot) continue
- result += path
+ for (path in roots.keys) {
+ result += listRecursively(path)
}
result.sort()
return result.toSet()
@@ -115,180 +168,433 @@ class FakeFileSystem(
|expected 0 open files, but found:
| ${openFiles.joinToString(separator = "\n ") { it.canonicalPath.toString() }}
""".trimMargin(),
- firstOpenFile.backtrace
+ firstOpenFile.backtrace,
)
}
+ /**
+ * Configure this file system to use a Windows-like working directory (`F:\`, unless the working
+ * directory is already Windows-like) and to follow a Windows-like policy on what operations
+ * are permitted.
+ */
+ fun emulateWindows() {
+ if ("\\" !in workingDirectory.toString()) {
+ workingDirectory = "F:\\".toPath()
+ }
+ allowMovingOpenFiles = false
+ allowDeletingOpenFiles = false
+ allowClobberingEmptyDirectories = true
+ allowWritesWhileWriting = true
+ allowReadsWhileWriting = true
+ }
+
+ /**
+ * Configure this file system to use a UNIX-like working directory (`/`, unless the working
+ * directory is already UNIX-like) and to follow a UNIX-like policy on what operations are
+ * permitted.
+ */
+ fun emulateUnix() {
+ if ("/" !in workingDirectory.toString()) {
+ workingDirectory = "/".toPath()
+ }
+ allowMovingOpenFiles = true
+ allowDeletingOpenFiles = true
+ allowClobberingEmptyDirectories = false
+ allowWritesWhileWriting = true
+ allowReadsWhileWriting = true
+ allowSymlinks = true
+ }
+
override fun canonicalize(path: Path): Path {
- val canonicalPath = workingDirectory / path
+ val canonicalPath = canonicalizeInternal(path)
- if (canonicalPath !in elements) {
+ val lookupResult = lookupPath(canonicalPath)
+ if (lookupResult?.element == null) {
throw FileNotFoundException("no such file: $path")
}
- return canonicalPath
+ return lookupResult.path
}
- override fun metadataOrNull(path: Path): FileMetadata? {
- val canonicalPath = workingDirectory / path
- var element = elements[canonicalPath]
+ /** Don't throw [FileNotFoundException] if the path doesn't identify a file. */
+ private fun canonicalizeInternal(path: Path): Path {
+ return workingDirectory.resolve(path, normalize = true)
+ }
- // If the path is a root, create it on demand.
- if (element == null && path.isRoot) {
- element = Directory(createdAt = clock.now())
- elements[path] = element
+ /**
+ * Sets the metadata of type [type] on [path] to [value]. If [value] is null this clears that
+ * metadata.
+ *
+ * Extras are not copied by [copy] but they are moved with [atomicMove].
+ *
+ * @throws IOException if [path] does not exist.
+ */
+ @Throws(IOException::class)
+ fun <T : Any> setExtra(path: Path, type: KClass<out T>, value: T?) {
+ val canonicalPath = canonicalizeInternal(path)
+ val lookupResult = lookupPath(
+ canonicalPath = canonicalPath,
+ createRootOnDemand = canonicalPath.isRoot,
+ resolveLastSymlink = false,
+ )
+ val element = lookupResult?.element ?: throw FileNotFoundException("no such file: $path")
+ if (value == null) {
+ element.extras.remove(type)
+ } else {
+ element.extras[type] = value
}
+ }
- return element?.metadata
+ override fun metadataOrNull(path: Path): FileMetadata? {
+ val canonicalPath = canonicalizeInternal(path)
+ val lookupResult = lookupPath(
+ canonicalPath = canonicalPath,
+ createRootOnDemand = canonicalPath.isRoot,
+ resolveLastSymlink = false,
+ )
+ return lookupResult?.element?.metadata
}
- override fun list(dir: Path): List<Path> {
- val canonicalPath = workingDirectory / dir
- val element = requireDirectory(canonicalPath)
+ override fun list(dir: Path): List<Path> = list(dir, throwOnFailure = true)!!
+
+ override fun listOrNull(dir: Path): List<Path>? = list(dir, throwOnFailure = false)
+
+ private fun list(dir: Path, throwOnFailure: Boolean): List<Path>? {
+ val canonicalPath = canonicalizeInternal(dir)
+ val lookupResult = lookupPath(canonicalPath)
+ if (lookupResult?.element == null) {
+ if (throwOnFailure) throw FileNotFoundException("no such directory: $dir") else return null
+ }
+ val element = lookupResult.element as? Directory
+ ?: if (throwOnFailure) throw IOException("not a directory: $dir") else return null
element.access(now = clock.now())
- val paths = elements.keys.filterTo(mutableListOf()) { it.parent == canonicalPath }
- paths.sort()
- return paths
+ return element.children.keys.map { dir / it }.sorted()
}
override fun source(file: Path): Source {
- val canonicalPath = workingDirectory / file
- val element = elements[canonicalPath] ?: throw FileNotFoundException("no such file: $file")
+ val fileHandle = openReadOnly(file)
+ return fileHandle.source()
+ .also { fileHandle.close() }
+ }
- if (element !is File) {
- throw IOException("not a file: $file")
- }
+ override fun sink(file: Path, mustCreate: Boolean): Sink {
+ val fileHandle = open(file, readWrite = true, mustCreate = mustCreate)
+ fileHandle.resize(0L) // If the file already has data, get rid of it.
+ return fileHandle.sink()
+ .also { fileHandle.close() }
+ }
- 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 appendingSink(file: Path, mustExist: Boolean): Sink {
+ val fileHandle = open(file, readWrite = true, mustExist = mustExist)
+ return fileHandle.appendingSink()
+ .also { fileHandle.close() }
}
- override fun sink(file: Path): Sink {
- return newSink(file, append = false)
+ override fun openReadOnly(file: Path): FileHandle {
+ return open(file, readWrite = false)
}
- override fun appendingSink(file: Path): Sink {
- return newSink(file, append = true)
+ override fun openReadWrite(file: Path, mustCreate: Boolean, mustExist: Boolean): FileHandle {
+ return open(file, readWrite = true, mustCreate = mustCreate, mustExist = mustExist)
}
- private fun newSink(file: Path, append: Boolean): Sink {
- val canonicalPath = workingDirectory / file
+ private fun open(
+ file: Path,
+ readWrite: Boolean,
+ mustCreate: Boolean = false,
+ mustExist: Boolean = false,
+ ): FileHandle {
+ require(!mustCreate || !mustExist) {
+ "Cannot require mustCreate and mustExist at the same time."
+ }
+
+ val canonicalPath = canonicalizeInternal(file)
+ val lookupResult = lookupPath(canonicalPath, createRootOnDemand = readWrite)
val now = clock.now()
+ val element: File
+ val operation: Operation
+
+ if (lookupResult?.element == null && mustExist) {
+ throw IOException("$file doesn't exist.")
+ }
+ if (lookupResult?.element != null && mustCreate) {
+ throw IOException("$file already exists.")
+ }
- val existing = elements[canonicalPath]
- if (existing is Directory) {
- throw IOException("destination is a directory: $file")
+ if (readWrite) {
+ // Note that this case is used for both write and read/write.
+ if (lookupResult?.element is Directory) {
+ throw IOException("destination is a directory: $file")
+ }
+ if (!allowWritesWhileWriting) {
+ findOpenFile(canonicalPath, operation = WRITE)?.let {
+ throw IOException("file is already open for writing $file", it.backtrace)
+ }
+ }
+ if (!allowReadsWhileWriting) {
+ findOpenFile(canonicalPath, operation = READ)?.let {
+ throw IOException("file is already open for reading $file", it.backtrace)
+ }
+ }
+
+ val parent = lookupResult?.parent
+ ?: throw FileNotFoundException("parent directory does not exist")
+ parent.access(now, true)
+
+ val existing = lookupResult.element
+ element = File(createdAt = existing?.createdAt ?: now)
+ parent.children[lookupResult.segment!!] = element
+ operation = WRITE
+
+ if (existing is File) {
+ element.data = existing.data
+ }
+ } else {
+ val existing = lookupResult?.element
+ ?: throw FileNotFoundException("no such file: $file")
+ element = existing as? File ?: throw IOException("not a file: $file")
+ operation = READ
+
+ if (!allowReadsWhileWriting) {
+ findOpenFile(canonicalPath, operation = WRITE)?.let {
+ throw IOException("file is already open for writing $file", it.backtrace)
+ }
+ }
}
- val parent = requireDirectory(canonicalPath.parent)
- parent.access(now, true)
- val openFile = OpenFile(canonicalPath, Exception("file opened for writing here"))
+ element.access(now = clock.now(), modified = readWrite)
+
+ val openFile = OpenFile(canonicalPath, operation, Exception("file opened for $operation 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
+
+ return FakeFileHandle(
+ readWrite = readWrite,
+ openFile = openFile,
+ file = element,
+ )
}
- override fun createDirectory(dir: Path) {
- val canonicalPath = workingDirectory / dir
+ override fun createDirectory(dir: Path, mustCreate: Boolean) {
+ val canonicalPath = canonicalizeInternal(dir)
+
+ val lookupResult = lookupPath(canonicalPath, createRootOnDemand = true)
+
+ if (canonicalPath.isRoot) {
+ // Looking it up was sufficient. Don't crash when creating roots that already exist.
+ return
+ }
- if (elements[canonicalPath] != null) {
+ if (mustCreate && lookupResult?.element != null) {
throw IOException("already exists: $dir")
}
- requireDirectory(canonicalPath.parent)
- elements[canonicalPath] = Directory(createdAt = clock.now())
+ val parentDirectory = lookupResult.requireParent()
+ parentDirectory.children[canonicalPath.nameBytes] = Directory(createdAt = clock.now())
}
- override fun atomicMove(source: Path, target: Path) {
- val canonicalSource = workingDirectory / source
- val canonicalTarget = workingDirectory / target
+ override fun atomicMove(
+ source: Path,
+ target: Path,
+ ) {
+ val canonicalSource = canonicalizeInternal(source)
+ val canonicalTarget = canonicalizeInternal(target)
- val targetElement = elements[canonicalTarget]
- val sourceElement = elements[canonicalSource]
+ val targetLookupResult = lookupPath(canonicalTarget, createRootOnDemand = true)
+ val sourceLookupResult = lookupPath(canonicalSource, resolveLastSymlink = false)
// Universal constraints.
- if (targetElement is Directory) {
+ if (targetLookupResult?.element is Directory) {
throw IOException("target is a directory: $target")
}
- requireDirectory(canonicalTarget.parent)
- if (windowsLimitations) {
- // Windows-only constraints.
- openFileOrNull(canonicalSource)?.let {
+ val targetParent = targetLookupResult.requireParent()
+ if (!allowMovingOpenFiles) {
+ findOpenFile(canonicalSource)?.let {
throw IOException("source is open $source", it.backtrace)
}
- openFileOrNull(canonicalTarget)?.let {
+ findOpenFile(canonicalTarget)?.let {
throw IOException("target is open $target", it.backtrace)
}
- } else {
- // UNIX-only constraints.
- if (sourceElement is Directory && targetElement is File) {
+ }
+ if (!allowClobberingEmptyDirectories) {
+ if (sourceLookupResult?.element is Directory && targetLookupResult?.element is File) {
throw IOException("source is a directory and target is a file")
}
}
- val removed = elements.remove(canonicalSource)
+ val sourceParent = sourceLookupResult.requireParent()
+ val removed = sourceParent.children.remove(canonicalSource.nameBytes)
?: throw FileNotFoundException("source doesn't exist: $source")
- elements[canonicalTarget] = removed
+ targetParent.children[canonicalTarget.nameBytes] = removed
}
- override fun delete(path: Path) {
- val canonicalPath = workingDirectory / path
+ override fun delete(path: Path, mustExist: Boolean) {
+ val canonicalPath = canonicalizeInternal(path)
+
+ val lookupResult = lookupPath(
+ canonicalPath = canonicalPath,
+ createRootOnDemand = true,
+ resolveLastSymlink = false,
+ )
- if (elements.keys.any { it.parent == canonicalPath }) {
+ if (lookupResult?.element == null) {
+ if (mustExist) {
+ throw FileNotFoundException("no such file: $path")
+ } else {
+ return
+ }
+ }
+
+ if (lookupResult.element is Directory && lookupResult.element.children.isNotEmpty()) {
throw IOException("non-empty directory")
}
- if (windowsLimitations) {
- openFileOrNull(canonicalPath)?.let {
+ if (!allowDeletingOpenFiles) {
+ findOpenFile(canonicalPath)?.let {
throw IOException("file is open $path", it.backtrace)
}
}
- if (elements.remove(canonicalPath) == null) {
- throw FileNotFoundException("no such file: $path")
+ val directory = lookupResult.requireParent()
+ directory.children.remove(canonicalPath.nameBytes)
+ }
+
+ override fun createSymlink(
+ source: Path,
+ target: Path,
+ ) {
+ val canonicalSource = canonicalizeInternal(source)
+
+ val existingLookupResult = lookupPath(canonicalSource, createRootOnDemand = true)
+ if (existingLookupResult?.element != null) {
+ throw IOException("already exists: $source")
}
+ val parent = existingLookupResult.requireParent()
+
+ if (!allowSymlinks) {
+ throw IOException("symlinks are not supported")
+ }
+
+ parent.children[canonicalSource.nameBytes] = Symlink(createdAt = clock.now(), target)
}
/**
- * Gets the directory at [path], creating it if [path] is a file system root.
+ * Walks the file system looking for [canonicalPath], following symlinks encountered along the
+ * way. This function is designed to be used both when looking up existing files and when creating
+ * new files into an existing directory.
+ *
+ * It returns either:
+ *
+ * * a path lookup result with an element if that file or directory or symlink exists. This is
+ * useful when reading or writing an existing fie.
+ *
+ * * a path lookup result that only got as far as the canonical path's parent, if the parent
+ * exists but the child file does not. This is useful when creating a new file.
*
- * @throws IOException if the named directory is not a root and does not exist, or if it does
- * exist but is not a directory.
+ * * null, if not even the parent directory exists. A file cannot yet be created with this path
+ * because there is no parent to attach it to.
+ *
+ * This will create the root of the returned path if it does not exist.
+ *
+ * @param canonicalPath a normalized path, typically the result of [FakeFileSystem.canonicalizeInternal].
+ * @param recurseCount used internally to detect cycles.
+ * @param resolveLastSymlink true if the result's element must not itself be a symlink. Use this
+ * for looking up metadata, or operations that apply to the path like delete and move. We
+ * always follow symlinks for enclosing directories.
+ * @param createRootOnDemand true to create a root directory like `C:\` or `/` if it doesn't
+ * exist. Pass true for mutating operations.
*/
- private fun requireDirectory(path: Path?): Directory {
- if (path == null) throw IOException("directory does not exist")
+ private fun lookupPath(
+ canonicalPath: Path,
+ recurseCount: Int = 0,
+ resolveLastSymlink: Boolean = true,
+ createRootOnDemand: Boolean = false,
+ ): PathLookupResult? {
+ // 40 is chosen for consistency with the Linux kernel (which previously used 8).
+ if (recurseCount > 40) {
+ throw IOException("symlink cycle?")
+ }
- // If the path is a directory, return it!
- val element = elements[path]
- if (element is Directory) return element
+ val rootPath = canonicalPath.root!!
+ var root = roots[rootPath]
- // 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 the path is a root, create it on demand.
+ if (root == null) {
+ if (!createRootOnDemand) return null
+ root = Directory(createdAt = clock.now())
+ roots[rootPath] = root
}
- if (element == null) throw FileNotFoundException("no such directory: $path")
+ var parent: Directory? = null
+ var lastSegment: ByteString? = null
+ var current: Element = root
+ var currentPath: Path = rootPath
+
+ var segmentsTraversed = 0
+ val segments = canonicalPath.segmentsBytes
+ for (segment in segments) {
+ lastSegment = segment
- throw IOException("not a directory: $path")
+ // Push the newest segment.
+ if (current !is Directory) {
+ throw IOException("not a directory: $currentPath")
+ }
+ parent = current
+ current = current.children[segment] ?: break
+ currentPath /= segment
+ segmentsTraversed++
+
+ // If it's a symlink, recurse to follow it.
+ val isLastSegment = segmentsTraversed == segments.size
+ val followSymlinks = !isLastSegment || resolveLastSymlink
+ if (current is Symlink && followSymlinks) {
+ current.access(now = clock.now())
+ // We wanna normalize it in case the target is relative and starts with `..`.
+ currentPath = currentPath.parent!!.resolve(current.target, normalize = true)
+ val symlinkLookupResult = lookupPath(
+ canonicalPath = currentPath,
+ recurseCount = recurseCount + 1,
+ createRootOnDemand = createRootOnDemand,
+ ) ?: break
+ parent = symlinkLookupResult.parent
+ lastSegment = symlinkLookupResult.segment
+ current = symlinkLookupResult.element ?: break
+ currentPath = symlinkLookupResult.path
+ }
+ }
+
+ return when (segmentsTraversed) {
+ segments.size -> {
+ PathLookupResult(currentPath, parent, lastSegment, current) // The file.
+ }
+ segments.size - 1 -> {
+ PathLookupResult(currentPath, parent, lastSegment, null) // The enclosing directory.
+ }
+ else -> null // We found nothing.
+ }
+ }
+
+ private class PathLookupResult(
+ /** The canonical path for the looked up path or its enclosing directory. */
+ val path: Path,
+ /** Only null if the looked up path is a root. */
+ val parent: Directory?,
+ /** Only null if the looked up path is a root. */
+ val segment: ByteString?,
+ /** Non-null if this is a root. Also not null if this file exists. */
+ val element: Element?,
+ )
+
+ private fun PathLookupResult?.requireParent(): Directory {
+ return this?.parent ?: throw IOException("parent directory does not exist")
}
private sealed class Element(
- val createdAt: Instant
+ val createdAt: Instant,
) {
var lastModifiedAt: Instant = createdAt
var lastAccessedAt: Instant = createdAt
+ val extras = mutableMapOf<KClass<*>, Any>()
class File(createdAt: Instant) : Element(createdAt) {
var data: ByteString = ByteString.EMPTY
@@ -299,21 +605,44 @@ class FakeFileSystem(
size = data.size.toLong(),
createdAt = createdAt,
lastModifiedAt = lastModifiedAt,
- lastAccessedAt = lastAccessedAt
+ lastAccessedAt = lastAccessedAt,
+ extras = extras,
)
}
class Directory(createdAt: Instant) : Element(createdAt) {
+ /** Keys are path segments. */
+ val children = mutableMapOf<ByteString, Element>()
+
override val metadata: FileMetadata
get() = FileMetadata(
isDirectory = true,
createdAt = createdAt,
lastModifiedAt = lastModifiedAt,
- lastAccessedAt = lastAccessedAt
+ lastAccessedAt = lastAccessedAt,
+ extras = extras,
+ )
+ }
+
+ class Symlink(
+ createdAt: Instant,
+ /** This may be an absolute or relative path. */
+ val target: Path,
+ ) : Element(createdAt) {
+ override val metadata: FileMetadata
+ get() = FileMetadata(
+ symlinkTarget = target,
+ createdAt = createdAt,
+ lastModifiedAt = lastModifiedAt,
+ lastAccessedAt = lastAccessedAt,
+ extras = extras,
)
}
- fun access(now: Instant, modified: Boolean = false) {
+ fun access(
+ now: Instant,
+ modified: Boolean = false,
+ ) {
lastAccessedAt = now
if (modified) {
lastModifiedAt = now
@@ -323,68 +652,116 @@ class FakeFileSystem(
abstract val metadata: FileMetadata
}
- private fun openFileOrNull(canonicalPath: Path): OpenFile? {
- return openFiles.firstOrNull { it.canonicalPath == canonicalPath }
+ private fun findOpenFile(
+ canonicalPath: Path,
+ operation: Operation? = null,
+ ): OpenFile? {
+ return openFiles.firstOrNull {
+ it.canonicalPath == canonicalPath && (operation == null || operation == it.operation)
+ }
+ }
+
+ private fun checkOffsetAndCount(
+ size: Long,
+ offset: Long,
+ byteCount: Long,
+ ) {
+ if (offset or byteCount < 0 || offset > size || size - offset < byteCount) {
+ throw ArrayIndexOutOfBoundsException("size=$size offset=$offset byteCount=$byteCount")
+ }
}
private class OpenFile(
val canonicalPath: Path,
- val backtrace: Throwable
+ val operation: Operation,
+ val backtrace: Throwable,
)
- /** Reads data from [buffer], removing itself from [openPathsMutable] when closed. */
- private inner class FakeFileSource(
+ private enum class Operation {
+ READ,
+ WRITE,
+ }
+
+ private inner class FakeFileHandle(
+ readWrite: Boolean,
private val openFile: OpenFile,
- private val buffer: Buffer
- ) : Source {
+ private val file: File,
+ ) : FileHandle(readWrite) {
private var closed = false
- override fun read(sink: Buffer, byteCount: Long): Long {
+ override fun protectedResize(size: Long) {
check(!closed) { "closed" }
- return buffer.read(sink, byteCount)
- }
- override fun timeout() = Timeout.NONE
+ val delta = size - file.data.size
+ if (delta > 0) {
+ file.data = Buffer()
+ .write(file.data)
+ .write(ByteArray(delta.toInt()))
+ .readByteString()
+ } else {
+ file.data = file.data.substring(0, size.toInt())
+ }
- override fun close() {
- if (closed) return
- closed = true
- openFiles -= openFile
+ file.access(now = clock.now(), modified = true)
}
- 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 protectedSize(): Long {
+ check(!closed) { "closed" }
+ return file.data.size.toLong()
+ }
- override fun write(source: Buffer, byteCount: Long) {
+ override fun protectedRead(
+ fileOffset: Long,
+ array: ByteArray,
+ arrayOffset: Int,
+ byteCount: Int,
+ ): Int {
check(!closed) { "closed" }
- buffer.write(source, byteCount)
+ checkOffsetAndCount(array.size.toLong(), arrayOffset.toLong(), byteCount.toLong())
+
+ val fileOffsetInt = fileOffset.toInt()
+ val toCopy = minOf(file.data.size - fileOffsetInt, byteCount)
+ if (toCopy <= 0) return -1
+ for (i in 0 until toCopy) {
+ array[i + arrayOffset] = file.data[i + fileOffsetInt]
+ }
+ return toCopy
}
- override fun flush() {
+ override fun protectedWrite(
+ fileOffset: Long,
+ array: ByteArray,
+ arrayOffset: Int,
+ byteCount: Int,
+ ) {
check(!closed) { "closed" }
+ checkOffsetAndCount(array.size.toLong(), arrayOffset.toLong(), byteCount.toLong())
+
+ val buffer = Buffer()
+ buffer.write(file.data, 0, minOf(fileOffset.toInt(), file.data.size))
+ while (buffer.size < fileOffset) {
+ buffer.writeByte(0)
+ }
+ buffer.write(array, arrayOffset, byteCount)
+ if (buffer.size < file.data.size) {
+ buffer.write(file.data, buffer.size.toInt(), file.data.size - buffer.size.toInt())
+ }
file.data = buffer.snapshot()
file.access(now = clock.now(), modified = true)
}
- override fun timeout() = Timeout.NONE
+ override fun protectedFlush() {
+ check(!closed) { "closed" }
+ }
- override fun close() {
+ override fun protectedClose() {
if (closed) return
closed = true
- file.data = buffer.snapshot()
- file.access(now = clock.now(), modified = true)
+ file.access(now = clock.now(), modified = readWrite)
openFiles -= openFile
}
- override fun toString() = "sink(${openFile.canonicalPath})"
+ override fun toString() = "FileHandler(${openFile.canonicalPath})"
}
override fun toString() = "FakeFileSystem"