diff --git a/okio/src/jvmMain/kotlin/okio/internal/ResourceFileSystem.kt b/okio/src/jvmMain/kotlin/okio/internal/ResourceFileSystem.kt index 51d5071428..efe7680132 100644 --- a/okio/src/jvmMain/kotlin/okio/internal/ResourceFileSystem.kt +++ b/okio/src/jvmMain/kotlin/okio/internal/ResourceFileSystem.kt @@ -28,6 +28,7 @@ import okio.Path.Companion.toOkioPath import okio.Path.Companion.toPath import okio.Sink import okio.Source +import okio.source /** * A file system exposing Java classpath resources. It is equivalent to the files returned by @@ -41,8 +42,9 @@ import okio.Source * This file system is read-only. */ internal class ResourceFileSystem internal constructor( - classLoader: ClassLoader, + private val classLoader: ClassLoader, indexEagerly: Boolean, + private val systemFileSystem: FileSystem = SYSTEM, ) : FileSystem() { private val roots: List> by lazy { classLoader.toClasspathRoots() } @@ -122,14 +124,10 @@ internal class ResourceFileSystem internal constructor( override fun source(file: Path): Source { if (!keepPath(file)) throw FileNotFoundException("file not found: $file") - val relativePath = file.toRelativePath() - for ((fileSystem, base) in roots) { - try { - return fileSystem.source(base / relativePath) - } catch (_: FileNotFoundException) { - } - } - throw FileNotFoundException("file not found: $file") + // Make sure we have a path that doesn't start with '/'. + val relativePath = ROOT.resolve(file).relativeTo(ROOT) + return classLoader.getResourceAsStream(relativePath.toString())?.source() + ?: throw FileNotFoundException("file not found: $file") } override fun sink(file: Path, mustCreate: Boolean): Sink { @@ -157,6 +155,47 @@ internal class ResourceFileSystem internal constructor( return canonicalThis.relativeTo(ROOT).toString() } + /** + * Returns a search path of classpath roots. Each element contains a file system to use, and + * the base directory of that file system to search from. + */ + private fun ClassLoader.toClasspathRoots(): List> { + // We'd like to build this upon an API like ClassLoader.getURLs() but unfortunately that + // API exists only on URLClassLoader (and that isn't the default class loader implementation). + // + // The closest we have is `ClassLoader.getResources("")`. It returns all classpath roots that + // are directories but none that are .jar files. To mitigate that we also search for all + // `META-INF/MANIFEST.MF` files, hastily assuming that every .jar file will have such an + // entry. + // + // Classpath entries that aren't directories and don't have a META-INF/MANIFEST.MF file will + // not be visible in this file system. + return getResources("").toList().mapNotNull { it.toFileRoot() } + + getResources("META-INF/MANIFEST.MF").toList().mapNotNull { it.toJarRoot() } + } + + private fun URL.toFileRoot(): Pair? { + if (protocol != "file") return null // Ignore unexpected URLs. + return systemFileSystem to File(toURI()).toOkioPath() + } + + private fun URL.toJarRoot(): Pair? { + val urlString = toString() + if (!urlString.startsWith("jar:file:")) return null // Ignore unexpected URLs. + + // Given a URL like `jar:file:/tmp/foo.jar!/META-INF/MANIFEST.MF`, get the path to the archive + // file, like `/tmp/foo.jar`. + val suffixStart = urlString.lastIndexOf("!") + if (suffixStart == -1) return null + val path = File(URI.create(urlString.substring("jar:".length, suffixStart))).toOkioPath() + val zip = openZip( + zipPath = path, + fileSystem = systemFileSystem, + predicate = { entry -> keepPath(entry.canonicalPath) }, + ) + return zip to ROOT + } + private companion object { val ROOT = "/".toPath() @@ -165,47 +204,6 @@ internal class ResourceFileSystem internal constructor( return ROOT / (toString().removePrefix(prefix).replace('\\', '/')) } - /** - * Returns a search path of classpath roots. Each element contains a file system to use, and - * the base directory of that file system to search from. - */ - fun ClassLoader.toClasspathRoots(): List> { - // We'd like to build this upon an API like ClassLoader.getURLs() but unfortunately that - // API exists only on URLClassLoader (and that isn't the default class loader implementation). - // - // The closest we have is `ClassLoader.getResources("")`. It returns all classpath roots that - // are directories but none that are .jar files. To mitigate that we also search for all - // `META-INF/MANIFEST.MF` files, hastily assuming that every .jar file will have such an - // entry. - // - // Classpath entries that aren't directories and don't have a META-INF/MANIFEST.MF file will - // not be visible in this file system. - return getResources("").toList().mapNotNull { it.toFileRoot() } + - getResources("META-INF/MANIFEST.MF").toList().mapNotNull { it.toJarRoot() } - } - - fun URL.toFileRoot(): Pair? { - if (protocol != "file") return null // Ignore unexpected URLs. - return SYSTEM to File(toURI()).toOkioPath() - } - - fun URL.toJarRoot(): Pair? { - val urlString = toString() - if (!urlString.startsWith("jar:file:")) return null // Ignore unexpected URLs. - - // Given a URL like `jar:file:/tmp/foo.jar!/META-INF/MANIFEST.MF`, get the path to the archive - // file, like `/tmp/foo.jar`. - val suffixStart = urlString.lastIndexOf("!") - if (suffixStart == -1) return null - val path = File(URI.create(urlString.substring("jar:".length, suffixStart))).toOkioPath() - val zip = openZip( - zipPath = path, - fileSystem = SYSTEM, - predicate = { entry -> keepPath(entry.canonicalPath) }, - ) - return zip to ROOT - } - private fun keepPath(path: Path) = !path.name.endsWith(".class", ignoreCase = true) } } diff --git a/okio/src/jvmTest/kotlin/okio/internal/ResourceFileSystemTest.kt b/okio/src/jvmTest/kotlin/okio/internal/ResourceFileSystemTest.kt index 84d72b8e0d..094d46ab5b 100644 --- a/okio/src/jvmTest/kotlin/okio/internal/ResourceFileSystemTest.kt +++ b/okio/src/jvmTest/kotlin/okio/internal/ResourceFileSystemTest.kt @@ -24,7 +24,9 @@ import kotlin.test.assertFailsWith import kotlin.test.fail import okio.BufferedSource import okio.ByteString +import okio.FileNotFoundException import okio.FileSystem +import okio.ForwardingFileSystem import okio.IOException import okio.Path import okio.Path.Companion.toPath @@ -48,6 +50,7 @@ class ResourceFileSystemTest { assertThat(metadata.isDirectory).isFalse() val content = fileSystem.read(path) { readUtf8() } + assertThat(fileSystem.metadata(path).isRegularFile).isTrue() assertThat(content).isEqualTo("a") } @@ -63,6 +66,7 @@ class ResourceFileSystemTest { assertThat(metadata.isDirectory).isFalse() val content = fileSystem.read(path) { readUtf8() } + assertThat(fileSystem.metadata(path).isRegularFile).isTrue() assertThat(content).isEqualTo("b/b") } @@ -81,9 +85,15 @@ class ResourceFileSystemTest { assertThat(resourceFileSystem.read("hello.txt".toPath()) { readUtf8() }) .isEqualTo("Hello World") + assertThat( + resourceFileSystem.metadata("hello.txt".toPath()).isRegularFile + ).isTrue() assertThat(resourceFileSystem.read("directory/subdirectory/child.txt".toPath()) { readUtf8() }) .isEqualTo("Another file!") + assertThat( + resourceFileSystem.metadata("directory/subdirectory/child.txt".toPath()).isRegularFile + ).isTrue() assertThat(resourceFileSystem.list("/".toPath())) .hasSameElementsAs(listOf("/META-INF".toPath(), "/hello.txt".toPath(), "/directory".toPath())) @@ -126,6 +136,10 @@ class ResourceFileSystemTest { assertThat(resourceFileSystem.read("/colors/blue.txt".toPath()) { readUtf8() }) .isEqualTo("The sky is blue") + assertThat(resourceFileSystem.metadata("/colors/red.txt".toPath()).isRegularFile).isTrue() + assertThat(resourceFileSystem.metadata("/colors/green.txt".toPath()).isRegularFile).isTrue() + assertThat(resourceFileSystem.metadata("/colors/blue.txt".toPath()).isRegularFile).isTrue() + assertThat(resourceFileSystem.list("/".toPath())) .hasSameElementsAs(listOf("/META-INF".toPath(), "/colors".toPath())) assertThat(resourceFileSystem.list("/colors".toPath())).hasSameElementsAs( @@ -169,6 +183,10 @@ class ResourceFileSystemTest { assertThat(resourceFileSystem.read("/colors/blue.txt".toPath()) { readUtf8() }) .isEqualTo("The sky is blue") + assertThat(resourceFileSystem.metadata("/colors/red.txt".toPath()).isRegularFile).isTrue() + assertThat(resourceFileSystem.metadata("/colors/green.txt".toPath()).isRegularFile).isTrue() + assertThat(resourceFileSystem.metadata("/colors/blue.txt".toPath()).isRegularFile).isTrue() + assertThat(resourceFileSystem.list("/".toPath())) .hasSameElementsAs(listOf("/colors".toPath())) assertThat(resourceFileSystem.list("/colors".toPath())).hasSameElementsAs( @@ -213,6 +231,10 @@ class ResourceFileSystemTest { assertThat(resourceFileSystem.read("/colors/blue.txt".toPath()) { readUtf8() }) .isEqualTo("The sky is blue") + assertThat(resourceFileSystem.metadata("/colors/red.txt".toPath()).isRegularFile).isTrue() + assertThat(resourceFileSystem.metadata("/colors/green.txt".toPath()).isRegularFile).isTrue() + assertThat(resourceFileSystem.metadata("/colors/blue.txt".toPath()).isRegularFile).isTrue() + assertThat(resourceFileSystem.list("/".toPath())) .hasSameElementsAs(listOf("/META-INF".toPath(), "/colors".toPath())) assertThat(resourceFileSystem.list("/colors".toPath())).hasSameElementsAs( @@ -292,6 +314,9 @@ class ResourceFileSystemTest { assertThat(resourceFileSystem.list("/com/example/project".toPath())).isEmpty() assertThat(resourceFileSystem.metadataOrNull("/com/example/project/Hello.class".toPath())) .isNull() + assertFailsWith { + resourceFileSystem.source("/com/example/project/Hello.class".toPath()) + } } @Test @@ -364,6 +389,57 @@ class ResourceFileSystemTest { ).hasMessage("finding a resource") } + /** Confirm we can read individual files without triggering indexing. */ + @Test + fun testSourceDoesntTriggerIndexing() { + val processedPaths = mutableSetOf() + val recordingFileSystem = object : ForwardingFileSystem(FileSystem.SYSTEM) { + override fun onPathParameter(path: Path, functionName: String, parameterName: String): Path { + processedPaths += path + return super.onPathParameter(path, functionName, parameterName) + } + } + + val zipAPath = ZipBuilder(base) + .addEntry("colors/red.txt", "Apples are red") + .addEntry("META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n") + .build() + val zipBPath = ZipBuilder(base) + .addEntry("colors/blue.txt", "The sky is blue") + .addEntry("META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n") + .build() + val resourceFileSystem = ResourceFileSystem( + classLoader = URLClassLoader( + arrayOf( + zipAPath.toFile().toURI().toURL(), + zipBPath.toFile().toURI().toURL(), + ), + null, + ), + indexEagerly = false, + systemFileSystem = recordingFileSystem, + ) + + // Reading paths with source() or read() doesn't index zips. + assertThat( + resourceFileSystem.read("/colors/red.txt".toPath()) { readUtf8() } + ).isEqualTo("Apples are red") + assertThat( + resourceFileSystem.read("/colors/blue.txt".toPath()) { readUtf8() } + ).isEqualTo("The sky is blue") + assertThat(processedPaths).isEmpty() + + // Calling list() does though. + assertThat(resourceFileSystem.list("/colors".toPath())).containsExactlyInAnyOrder( + "/colors/red.txt".toPath(), + "/colors/blue.txt".toPath(), + ) + assertThat(processedPaths).containsExactlyInAnyOrder( + zipAPath, + zipBPath, + ) + } + /** * Our resource file system uses [URLClassLoader] internally, which means we need to go back and * forth between [File], [URL], and [URI] models for component paths. This is a big hazard for @@ -382,6 +458,7 @@ class ResourceFileSystemTest { assertThat(resourceFileSystem.read("hello.txt".toPath()) { readUtf8() }) .isEqualTo("Hello World") + assertThat(resourceFileSystem.metadata("hello.txt".toPath())).isNotNull() } @Test