Skip to content

Commit

Permalink
Filesystem.createDirectories and deleteRecursively
Browse files Browse the repository at this point in the history
I've implemented each of these without making recursive function calls. This
is very defensive; I'm attempting to prevent StackOverflowErrors for deep
paths.
  • Loading branch information
squarejesse committed Dec 24, 2020
1 parent 94b73fb commit 2879474
Show file tree
Hide file tree
Showing 4 changed files with 200 additions and 10 deletions.
38 changes: 28 additions & 10 deletions okio-testing/src/commonMain/kotlin/okio/FakeFilesystem.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,26 @@ class FakeFilesystem(

private val openPathsMutable = mutableListOf<Path>()

/**
* Canonical paths for every file and directory in this filesystem. This omits filesystem roots
* like `C:\` and `/`.
*/
val allPaths: Set<Path>
get() {
val result = mutableSetOf<Path>()
for (path in elements.keys) {
if (path.parent == null) continue
result += path
}
return result
}

/**
* 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.
*/
val openPaths: List<Path>
get() = openPathsMutable.toList()
Expand All @@ -56,15 +73,15 @@ class FakeFilesystem(
val canonicalPath = workingDirectory / path

if (canonicalPath !in elements) {
throw IOException("no such file")
throw IOException("no such file: $path")
}

return canonicalPath
}

override fun metadata(path: Path): FileMetadata {
val canonicalPath = workingDirectory / path
val element = elements[canonicalPath] ?: throw IOException("no such file")
val element = elements[canonicalPath] ?: throw IOException("no such file: $path")
return element.metadata
}

Expand All @@ -78,10 +95,10 @@ class FakeFilesystem(

override fun source(file: Path): Source {
val canonicalPath = workingDirectory / file
val element = elements[canonicalPath] ?: throw IOException("no such file")
val element = elements[canonicalPath] ?: throw IOException("no such file: $file")

if (element !is File) {
throw IOException("not a file")
throw IOException("not a file: $file")
}

openPathsMutable += canonicalPath
Expand All @@ -103,7 +120,7 @@ class FakeFilesystem(

val existing = elements[canonicalPath]
if (existing is Directory) {
throw IOException("destination is a directory")
throw IOException("destination is a directory: $file")
}
val parent = requireDirectory(canonicalPath.parent)
parent.access(now, true)
Expand All @@ -124,7 +141,7 @@ class FakeFilesystem(
val canonicalPath = workingDirectory / dir

if (elements[canonicalPath] != null) {
throw IOException("already exists")
throw IOException("already exists: $dir")
}
requireDirectory(canonicalPath.parent)

Expand All @@ -140,7 +157,7 @@ class FakeFilesystem(

// Universal constraints.
if (targetElement is Directory) {
throw IOException("target is a directory")
throw IOException("target is a directory: $target")
}
requireDirectory(canonicalTarget.parent)
if (windowsLimitations) {
Expand All @@ -158,7 +175,8 @@ class FakeFilesystem(
}
}

val removed = elements.remove(canonicalSource) ?: throw IOException("source doesn't exist")
val removed = elements.remove(canonicalSource)
?: throw IOException("source doesn't exist: $source")
elements[canonicalTarget] = removed
}

Expand All @@ -174,7 +192,7 @@ class FakeFilesystem(
}

if (elements.remove(canonicalPath) == null) {
throw IOException("no such file")
throw IOException("no such file: $path")
}
}

Expand All @@ -198,7 +216,7 @@ class FakeFilesystem(
return root
}

throw IOException("path is not a directory")
throw IOException("path is not a directory: $path")
}

internal sealed class Element(
Expand Down
60 changes: 60 additions & 0 deletions okio/src/commonMain/kotlin/okio/Filesystem.kt
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,36 @@ abstract class Filesystem {
@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 && !isDirectory(path)) {
directories.addFirst(path)
path = path.parent
}

// Create them.
for (toCreate in directories) {
createDirectory(toCreate)
}
}

private fun isDirectory(dir: Path): Boolean {
return try {
metadata(dir).isDirectory
} catch (_: IOException) {
false // Maybe the file doesn't exist?
}
}

/**
* 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
Expand Down Expand Up @@ -240,6 +270,36 @@ abstract class Filesystem {
@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 filesystem. Use this instance directly, or dependency inject a
Expand Down
78 changes: 78 additions & 0 deletions okio/src/commonTest/kotlin/okio/AbstractFilesystemTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,40 @@ abstract class AbstractFilesystemTest(
}
}

@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"
Expand Down Expand Up @@ -320,6 +354,50 @@ abstract class AbstractFilesystemTest(
}
}

@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<IOException> {
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()
Expand Down
34 changes: 34 additions & 0 deletions okio/src/commonTest/kotlin/okio/FakeFilesystemTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,40 @@ abstract class FakeFilesystemTest internal constructor(
assertTrue(fakeFilesystem.openPaths.isEmpty())
}

@Test
fun allPathsIncludesFile() {
val file = base / "all-files-includes-file"
file.writeUtf8("hello, world!")
assertEquals(fakeFilesystem.allPaths, setOf(base, file))
}

@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"
Expand Down

0 comments on commit 2879474

Please sign in to comment.