aboutsummaryrefslogtreecommitdiff
path: root/okio-fakefilesystem/src/commonMain/kotlin/okio/fakefilesystem/FakeFileSystem.kt
blob: fb426744c63d1642d59e41d90a52fd0a273fee5b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
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"
}