diff --git a/app/src/main/java/com/runcode/app/runtime/StaticWebEngine.kt b/app/src/main/java/com/runcode/app/runtime/StaticWebEngine.kt index 65e8510..3d07ec2 100644 --- a/app/src/main/java/com/runcode/app/runtime/StaticWebEngine.kt +++ b/app/src/main/java/com/runcode/app/runtime/StaticWebEngine.kt @@ -232,9 +232,11 @@ class StaticWebEngine( /** * Maps a request path onto a file inside [sourceDir], falling back to the entrypoint so - * single-page apps keep working. Returns null when the path escapes the source directory. + * single-page apps keep working. Returns null when the path escapes the source directory, + * and a file that does not exist when the request names a missing file, which the caller + * answers with 404. */ - private fun resolveTarget(sourceDir: File, rawPath: String, entryPoint: String): File? { + internal fun resolveTarget(sourceDir: File, rawPath: String, entryPoint: String): File? { val decoded = try { URLDecoder.decode(rawPath, "UTF-8") } catch (_: Exception) { @@ -246,10 +248,23 @@ class StaticWebEngine( if (!isInside(candidate, sourceDir)) return null if (candidate.isFile) return candidate + // A single-page app routes /dashboard through index.html, so an unknown route falls + // back to the entrypoint. A request that names a file does not: answering /app.css + // with the HTML page, and a 200 at that, broke pages quietly and hid typos in asset + // paths. Missing files get a 404 instead. + if (namesAFile(requested)) return candidate + val fallback = File(sourceDir, entryPoint).canonicalFile return if (isInside(fallback, sourceDir)) fallback else null } + /** True when the last path segment carries an extension, e.g. `app.css` but not `dashboard`. */ + private fun namesAFile(path: String): Boolean { + val name = path.trimEnd('/').substringAfterLast('/') + val dot = name.lastIndexOf('.') + return dot > 0 && dot < name.length - 1 + } + private fun isInside(candidate: File, root: File): Boolean { return candidate.path == root.path || candidate.path.startsWith(root.path + File.separator) } diff --git a/app/src/test/java/com/runcode/app/runtime/StaticWebRoutingTest.kt b/app/src/test/java/com/runcode/app/runtime/StaticWebRoutingTest.kt new file mode 100644 index 0000000..32312d9 --- /dev/null +++ b/app/src/test/java/com/runcode/app/runtime/StaticWebRoutingTest.kt @@ -0,0 +1,65 @@ +package com.runcode.app.runtime + +import android.app.Application +import com.runcode.app.network.PortManager +import com.runcode.app.system.ProcessMonitor +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class StaticWebRoutingTest { + + private lateinit var engine: StaticWebEngine + private lateinit var sourceDir: File + + @Before fun setup() { + engine = StaticWebEngine(PortManager(), ProcessMonitor()) + sourceDir = Files.createTempDirectory("static-web").toFile() + File(sourceDir, "index.html").writeText("") + File(sourceDir, "style.css").writeText("body{}") + File(sourceDir, "assets").mkdirs() + File(sourceDir, "assets/app.js").writeText("// app") + } + + private fun resolve(path: String) = engine.resolveTarget(sourceDir, path, "index.html") + + @Test fun `root serves the entrypoint`() { + assertEquals(File(sourceDir, "index.html").canonicalFile, resolve("")) + } + + @Test fun `existing files are served`() { + assertEquals(File(sourceDir, "style.css").canonicalFile, resolve("style.css")) + assertEquals(File(sourceDir, "assets/app.js").canonicalFile, resolve("assets/app.js")) + } + + @Test fun `a route with no extension falls back to the entrypoint`() { + assertEquals(File(sourceDir, "index.html").canonicalFile, resolve("dashboard")) + assertEquals(File(sourceDir, "index.html").canonicalFile, resolve("users/42/")) + } + + @Test fun `a missing file is not answered with the entrypoint`() { + // It used to return index.html with a 200, so a typo in an asset path broke the page + // quietly. The caller turns a file that does not exist into a 404. + val missing = resolve("missing.css") + assertEquals(File(sourceDir, "missing.css").canonicalFile, missing) + assertFalse(missing!!.isFile) + + val missingNested = resolve("assets/nope.js") + assertEquals(File(sourceDir, "assets/nope.js").canonicalFile, missingNested) + assertFalse(missingNested!!.isFile) + } + + @Test fun `paths that escape the source directory are refused`() { + assertNull(resolve("../secrets.txt")) + assertNull(resolve("assets/../../secrets.txt")) + } +}