diff --git a/build.gradle.kts b/build.gradle.kts index b91d088..2b460f0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,11 +16,13 @@ repositories { dependencies { //implementation("org.eclipse.jgit:org.eclipse.jgit:7.3.0.202506031305-r") /// implementation("io.pebbletemplates:pebble:3.2.4") - implementation(libs.clikt) // command line argument parsing - implementation(libs.kaml) // YAML configuration parsing + implementation(libs.clikt) + implementation(libs.kaml) + implementation(libs.logging) testImplementation(kotlin("test")) testImplementation(libs.mockk) + testImplementation(libs.junit.jupiter.params) } tasks.test { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 33b5798..7e5511a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,11 +5,15 @@ shadow = "9.2.2" clikt = "5.0.3" kaml = "0.97.0" mockk = "1.14.5" +junit = "5.11.0" +logging = "7.0.3" [libraries] clikt = { group = "com.github.ajalt.clikt", name = "clikt", version.ref = "clikt" } kaml = { group = "com.charleskorn.kaml", name = "kaml", version.ref = "kaml" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit" } +logging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "logging" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/src/main/kotlin/eu/m724/bsk2/builder/PathUtils.kt b/src/main/kotlin/eu/m724/bsk2/builder/PathUtils.kt deleted file mode 100644 index 94e9cac..0000000 --- a/src/main/kotlin/eu/m724/bsk2/builder/PathUtils.kt +++ /dev/null @@ -1,10 +0,0 @@ -package eu.m724.bsk2.builder - -import java.nio.file.Path - -// TODO needs a test or two -fun Path.isParentOf(child: Path): Boolean = - child.toFile().canonicalPath.startsWith(this.toFile().canonicalPath) - -fun Path.contains(other: Path): Boolean = - isParentOf(other) \ No newline at end of file diff --git a/src/main/kotlin/eu/m724/bsk2/builder/FileSystem.kt b/src/main/kotlin/eu/m724/bsk2/builder/fs/FileSystem.kt similarity index 56% rename from src/main/kotlin/eu/m724/bsk2/builder/FileSystem.kt rename to src/main/kotlin/eu/m724/bsk2/builder/fs/FileSystem.kt index 5d79884..ac1c7c2 100644 --- a/src/main/kotlin/eu/m724/bsk2/builder/FileSystem.kt +++ b/src/main/kotlin/eu/m724/bsk2/builder/fs/FileSystem.kt @@ -1,7 +1,8 @@ -package eu.m724.bsk2.builder +package eu.m724.bsk2.builder.fs import java.nio.file.Path interface FileSystem { fun readText(path: Path): String + fun exists(path: Path): Boolean } \ No newline at end of file diff --git a/src/main/kotlin/eu/m724/bsk2/builder/fs/LocalFileSystem.kt b/src/main/kotlin/eu/m724/bsk2/builder/fs/LocalFileSystem.kt new file mode 100644 index 0000000..dceb5b0 --- /dev/null +++ b/src/main/kotlin/eu/m724/bsk2/builder/fs/LocalFileSystem.kt @@ -0,0 +1,11 @@ +package eu.m724.bsk2.builder.fs + +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText + +class LocalFileSystem : FileSystem { + override fun readText(path: Path): String = path.readText() + + override fun exists(path: Path): Boolean = path.exists() +} \ No newline at end of file diff --git a/src/main/kotlin/eu/m724/bsk2/builder/fs/PathUtils.kt b/src/main/kotlin/eu/m724/bsk2/builder/fs/PathUtils.kt new file mode 100644 index 0000000..0605c43 --- /dev/null +++ b/src/main/kotlin/eu/m724/bsk2/builder/fs/PathUtils.kt @@ -0,0 +1,38 @@ +package eu.m724.bsk2.builder.fs + +import java.nio.file.Path + +// TODO needs a test or two +fun Path.isParentOf(child: Path): Boolean = + child.toFile().canonicalPath.startsWith(this.toFile().canonicalPath) + +fun Path.contains(other: Path): Boolean = + isParentOf(other) + +/** + * Resolve the given path against this path, ensuring the resulting path is a child of the given path. + * + * @param other the path to resolve against this path + * @return the resulting path + * @throws IllegalArgumentException if the resolve results in a traversal + */ +fun Path.resolveSafe(other: Path): Path { + val path = this.resolve(other) + + if (!this.isParentOf(path)) { + throw IllegalArgumentException("Path traversal ($path not a child of $this)") + } + + return path +} + +/** + * Resolve the given path against this path, ensuring the resulting path is a child of the given path. + * + * @param other the path to resolve against this path + * @return the resulting path + * @throws IllegalArgumentException if the resolve results in a traversal + */ +fun Path.resolveSafe(other: String): Path { + return this.resolveSafe(fileSystem.getPath(other)) +} \ No newline at end of file diff --git a/src/main/kotlin/eu/m724/bsk2/builder/post/loader/FileSystemPostLoader.kt b/src/main/kotlin/eu/m724/bsk2/builder/post/loader/FileSystemPostLoader.kt index e539d43..3ab2dd0 100644 --- a/src/main/kotlin/eu/m724/bsk2/builder/post/loader/FileSystemPostLoader.kt +++ b/src/main/kotlin/eu/m724/bsk2/builder/post/loader/FileSystemPostLoader.kt @@ -2,13 +2,13 @@ package eu.m724.bsk2.builder.post.loader import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration -import eu.m724.bsk2.builder.FileSystem -import eu.m724.bsk2.builder.isParentOf +import eu.m724.bsk2.builder.fs.FileSystem +import eu.m724.bsk2.builder.fs.LocalFileSystem +import eu.m724.bsk2.builder.fs.resolveSafe import eu.m724.bsk2.builder.post.data.PostMeta import kotlinx.serialization.decodeFromString import java.nio.file.Path import kotlin.io.path.div -import kotlin.io.path.readText /** * A post loader that loads from a file system @@ -18,25 +18,41 @@ import kotlin.io.path.readText */ class FileSystemPostLoader ( private val postRoot: Path, - private val fileSystem: FileSystem = object : FileSystem { - override fun readText(path: Path): String = path.readText() - }, + private val fileSystem: FileSystem = LocalFileSystem(), private val yaml: Yaml = Yaml( configuration = YamlConfiguration(strictMode = false) ) ) : PostLoader { - private val cache = mutableMapOf() + private companion object { + val COVER_EXTENSIONS = listOf("webp", "png") + } - override fun getMeta(name: String): PostMeta { - val path = postRoot / name / "meta.yml" + override fun readMeta(name: String): PostMeta { + val path = postRoot.resolveSafe(name) / "meta.yml" - if (!postRoot.isParentOf(path)) { - throw IllegalArgumentException("Path traversal ($path not a child of $postRoot)") + val text = fileSystem.readText(path) + return yaml.decodeFromString(text) + } + + override fun readContent(name: String): String { + val path = postRoot.resolveSafe(name) / "content.html" + + return fileSystem.readText(path) + } + + override fun findCover(name: String): Path { + val postPath = postRoot.resolveSafe(name) + + val found = COVER_EXTENSIONS + .map { ext -> postPath / "cover.$ext" } + .filter { path -> fileSystem.exists(path) } + + if (found.isEmpty()) { + throw NoSuchFileException((postPath / "cover.$COVER_EXTENSIONS[0]").toFile(), reason = "No valid cover file found") + } else if (found.size > 1) { + throw IllegalStateException("Don't know which of ${found.size} cover files to pick: ${found.joinToString(", ")}") } - return cache.getOrPut(name) { - val text = fileSystem.readText(path) - yaml.decodeFromString(text) - } + return found.first() } } \ No newline at end of file diff --git a/src/main/kotlin/eu/m724/bsk2/builder/post/loader/PostLoader.kt b/src/main/kotlin/eu/m724/bsk2/builder/post/loader/PostLoader.kt index a96f8c9..b4c02a5 100644 --- a/src/main/kotlin/eu/m724/bsk2/builder/post/loader/PostLoader.kt +++ b/src/main/kotlin/eu/m724/bsk2/builder/post/loader/PostLoader.kt @@ -1,7 +1,10 @@ package eu.m724.bsk2.builder.post.loader import eu.m724.bsk2.builder.post.data.PostMeta +import java.nio.file.Path interface PostLoader { - fun getMeta(name: String): PostMeta + fun readMeta(name: String): PostMeta + fun readContent(name: String): String + fun findCover(name: String): Path } \ No newline at end of file diff --git a/src/main/kotlin/eu/m724/bsk2/builder/template/loader/FileSystemTemplateLoader.kt b/src/main/kotlin/eu/m724/bsk2/builder/template/loader/FileSystemTemplateLoader.kt index c83e000..599e9c9 100644 --- a/src/main/kotlin/eu/m724/bsk2/builder/template/loader/FileSystemTemplateLoader.kt +++ b/src/main/kotlin/eu/m724/bsk2/builder/template/loader/FileSystemTemplateLoader.kt @@ -1,10 +1,10 @@ package eu.m724.bsk2.builder.template.loader -import eu.m724.bsk2.builder.FileSystem -import eu.m724.bsk2.builder.isParentOf +import eu.m724.bsk2.builder.fs.FileSystem +import eu.m724.bsk2.builder.fs.LocalFileSystem +import eu.m724.bsk2.builder.fs.resolveSafe import java.nio.file.Path import kotlin.io.path.div -import kotlin.io.path.readText /** * A template loader that loads from a file system @@ -14,22 +14,13 @@ import kotlin.io.path.readText */ class FileSystemTemplateLoader ( templateRoot: Path, - private val fileSystem: FileSystem = object : FileSystem { - override fun readText(path: Path): String = path.readText() - } + private val fileSystem: FileSystem = LocalFileSystem() ) : TemplateLoader { - private val cache = mutableMapOf() private val htmlRoot = templateRoot / "html" - override fun getHtml(name: String): String { - val path = htmlRoot / "$name.html" + override fun readHtml(name: String): String { + val path = htmlRoot.resolveSafe("$name.html") - if (!htmlRoot.isParentOf(path)) { - throw IllegalArgumentException("Path traversal ($path not a child of $htmlRoot)") - } - - return cache.getOrPut(name) { - fileSystem.readText(path) - } + return fileSystem.readText(path) } } \ No newline at end of file diff --git a/src/main/kotlin/eu/m724/bsk2/builder/template/loader/TemplateLoader.kt b/src/main/kotlin/eu/m724/bsk2/builder/template/loader/TemplateLoader.kt index 5861cea..ef9fd1e 100644 --- a/src/main/kotlin/eu/m724/bsk2/builder/template/loader/TemplateLoader.kt +++ b/src/main/kotlin/eu/m724/bsk2/builder/template/loader/TemplateLoader.kt @@ -1,5 +1,5 @@ package eu.m724.bsk2.builder.template.loader interface TemplateLoader { - fun getHtml(name: String): String + fun readHtml(name: String): String } \ No newline at end of file diff --git a/src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderContentTest.kt b/src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderContentTest.kt new file mode 100644 index 0000000..9d10bc7 --- /dev/null +++ b/src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderContentTest.kt @@ -0,0 +1,64 @@ +package eu.m724.bsk2.builder.post.loader + +import eu.m724.bsk2.builder.fs.FileSystem +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.div +import kotlin.test.Test +import kotlin.test.assertEquals + +class PostLoaderContentTest { + private companion object { + val ROOT_PATH: Path = Paths.get("workspace", "posts") + + fun String.toContentHtmlPath() = ROOT_PATH / this / "content.html" + } + + private lateinit var fileSystem: FileSystem + private lateinit var loader: PostLoader + + @BeforeEach + fun setupTest() { + fileSystem = mockk() + + loader = FileSystemPostLoader( + postRoot = ROOT_PATH, + fileSystem = fileSystem + ) + } + + @AfterEach + fun cleanupTest() { + checkUnnecessaryStub(fileSystem) + unmockkAll() + } + + @Test + fun `readContent returns post content on success`() { + everyContentHtmlRead("first") returns "Hello, world!" + + assertEquals("Hello, world!", loader.readContent("first")) + } + + @Test + fun `readContent should throw NoSuchFileException`() { + everyContentHtmlRead("non existent") throws NoSuchFileException("non existent".toContentHtmlPath().toFile()) + + assertThrows(NoSuchFileException::class.java) { loader.readContent("non existent") } + } + + @Test + fun `readMeta should throw IllegalArgumentException for path traversal attempts`() { + assertThrows(IllegalArgumentException::class.java) { loader.readContent("../traversed") } + + // while not required, here for explicitness + verify(exactly = 0) { fileSystem.readText(any()) } + } + + private fun everyContentHtmlRead(name: String) = + every { fileSystem.readText(name.toContentHtmlPath()) } +} \ No newline at end of file diff --git a/src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderCoverTest.kt b/src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderCoverTest.kt new file mode 100644 index 0000000..e450cbb --- /dev/null +++ b/src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderCoverTest.kt @@ -0,0 +1,74 @@ +package eu.m724.bsk2.builder.post.loader + +import eu.m724.bsk2.builder.fs.FileSystem +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.div +import kotlin.test.assertEquals + +class PostLoaderCoverTest{ + private companion object { + val ROOT_PATH: Path = Paths.get("workspace", "posts") + + fun String.toCoverPath(extension: String = "webp") = ROOT_PATH / this / "cover.$extension" + } + + private lateinit var fileSystem: FileSystem + private lateinit var loader: PostLoader + + @BeforeEach + fun setupTest() { + fileSystem = mockk() + + loader = FileSystemPostLoader( + postRoot = ROOT_PATH, + fileSystem = fileSystem + ) + } + + @AfterEach + fun cleanupTest() { + checkUnnecessaryStub(fileSystem) + unmockkAll() + } + + @ParameterizedTest + @ValueSource(strings = ["webp", "png"]) + fun `findCover should find the path to cover`(extension: String) { + every { fileSystem.exists(any()) } returns false + every { fileSystem.exists("first".toCoverPath(extension)) } returns true + + assertEquals("first".toCoverPath(extension), loader.findCover("first")) + } + + @Test + fun `findCover should throw IllegalStateException when there are multiple matches`() { + //every { fileSystem.exists(any()) } returns false + every { fileSystem.exists("first".toCoverPath("webp")) } returns true + every { fileSystem.exists("first".toCoverPath("png")) } returns true + + assertThrows(IllegalStateException::class.java) { loader.findCover("first") } + } + + @Test + fun `findCover should throw NoSuchFileException`() { + every { fileSystem.exists(any()) } returns false + + assertThrows(NoSuchFileException::class.java) { loader.findCover("first") } + } + + @Test + fun `findCover should throw IllegalArgumentException for path traversal attempts`() { + assertThrows(IllegalArgumentException::class.java) { loader.findCover("../traversed") } + + // while not required, here for explicitness + verify(exactly = 0) { fileSystem.exists(any()) } + } +} diff --git a/src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderTest.kt b/src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderMetaTest.kt similarity index 67% rename from src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderTest.kt rename to src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderMetaTest.kt index 42bc832..b8e4c53 100644 --- a/src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderTest.kt +++ b/src/test/kotlin/eu/m724/bsk2/builder/post/loader/PostLoaderMetaTest.kt @@ -1,7 +1,7 @@ package eu.m724.bsk2.builder.post.loader import com.charleskorn.kaml.YamlException -import eu.m724.bsk2.builder.FileSystem +import eu.m724.bsk2.builder.fs.FileSystem import eu.m724.bsk2.builder.post.data.PostMeta import io.mockk.* import org.junit.jupiter.api.AfterEach @@ -13,7 +13,7 @@ import kotlin.io.path.div import kotlin.test.Test import kotlin.test.assertEquals -class PostLoaderTest { +class PostLoaderMetaTest { private companion object { val ROOT_PATH: Path = Paths.get("workspace", "posts") @@ -40,7 +40,7 @@ class PostLoaderTest { } @Test - fun `getPost should cache the result on success`() { + fun `readMeta should return the post meta on success`() { everyMetaYmlRead("first") returns """ title: Hello, world! summary: My first post @@ -66,39 +66,32 @@ class PostLoaderTest { summary = "My second post" ) - repeat(3) { assertEquals(expectedFirst, loader.getMeta("first")) } - repeat(3) { assertEquals(expectedSecond, loader.getMeta("second")) } + assertEquals(expectedFirst, loader.readMeta("first")) + assertEquals(expectedSecond, loader.readMeta("second")) - // ensure that cached verify(exactly = 1) { fileSystem.readText("first".toMetaYmlPath()) } verify(exactly = 1) { fileSystem.readText("second".toMetaYmlPath()) } } @Test - fun `getPost should throw YamlException on invalid meta YML file and not cache the failure`() { + fun `readMeta should throw YamlException on invalid meta YML file`() { everyMetaYmlRead("invalid") returns "Totally invalid" - repeat(3) { assertThrows(YamlException::class.java) { loader.getMeta("invalid") } } - - // ensure not cached - verify(exactly = 3) { fileSystem.readText("invalid".toMetaYmlPath()) } + assertThrows(YamlException::class.java) { loader.readMeta("invalid") } } @Test - fun `getPost should throw NoSuchFileException and not cache the failure`() { + fun `readMeta should throw NoSuchFileException`() { everyMetaYmlRead("non existent") throws NoSuchFileException("non existent".toMetaYmlPath().toFile()) - repeat(3) { assertThrows(NoSuchFileException::class.java) { loader.getMeta("non existent") } } - - // ensure not cached - verify(exactly = 3) { fileSystem.readText("non existent".toMetaYmlPath()) } + assertThrows(NoSuchFileException::class.java) { loader.readMeta("non existent") } } @Test - fun `getPost should throw IllegalArgumentException for path traversal attempts`() { - assertThrows(IllegalArgumentException::class.java) { loader.getMeta("../traversed") } + fun `readMeta should throw IllegalArgumentException for path traversal attempts`() { + assertThrows(IllegalArgumentException::class.java) { loader.readMeta("../traversed") } - // while not necessary, here for explicitness + // while not required, here for explicitness verify(exactly = 0) { fileSystem.readText(any()) } } diff --git a/src/test/kotlin/eu/m724/bsk2/builder/template/loader/TemplateLoaderTest.kt b/src/test/kotlin/eu/m724/bsk2/builder/template/loader/TemplateLoaderTest.kt index 4960108..b907f9c 100644 --- a/src/test/kotlin/eu/m724/bsk2/builder/template/loader/TemplateLoaderTest.kt +++ b/src/test/kotlin/eu/m724/bsk2/builder/template/loader/TemplateLoaderTest.kt @@ -1,6 +1,6 @@ package eu.m724.bsk2.builder.template.loader -import eu.m724.bsk2.builder.FileSystem +import eu.m724.bsk2.builder.fs.FileSystem import io.mockk.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertThrows @@ -14,8 +14,6 @@ import kotlin.test.assertEquals class TemplateLoaderTest { private companion object { val ROOT_PATH: Path = Paths.get("workspace", "template") - const val POST_TEMPLATE_TEXT = "This is the post template" - const val INDEX_TEMPLATE_TEXT = "This is the index template" fun String.toHtmlTemplatePath() = (ROOT_PATH / "html" / "$this.html") } @@ -40,43 +38,25 @@ class TemplateLoaderTest { } @Test - fun `getHtml should return file content on success`() { - everyHtmlTemplateRead("post") returns POST_TEMPLATE_TEXT - everyHtmlTemplateRead("index") returns INDEX_TEMPLATE_TEXT + fun `readHtml should return file content on success`() { + everyHtmlTemplateRead("post") returns "This is the post template" + everyHtmlTemplateRead("index") returns "This is the index template" - assertEquals(POST_TEMPLATE_TEXT, loader.getHtml("post")) - assertEquals(INDEX_TEMPLATE_TEXT, loader.getHtml("index")) + assertEquals("This is the post template", loader.readHtml("post")) + assertEquals("This is the index template", loader.readHtml("index")) } @Test - fun `getHtml should cache the result on success`() { - everyHtmlTemplateRead("post") returns POST_TEMPLATE_TEXT - everyHtmlTemplateRead("index") returns INDEX_TEMPLATE_TEXT - - repeat(3) { assertEquals(POST_TEMPLATE_TEXT, loader.getHtml("post")) } - repeat(3) { assertEquals(INDEX_TEMPLATE_TEXT, loader.getHtml("index")) } - - // ensure the reads are cached - verify(exactly = 1) { fileSystem.readText("post".toHtmlTemplatePath()) } - verify(exactly = 1) { fileSystem.readText("index".toHtmlTemplatePath()) } - } - - @Test - fun `getHtml should throw NoSuchFileException and not cache the failure`() { + fun `readHtml should throw NoSuchFileException`() { everyHtmlTemplateRead("non existent") throws NoSuchFileException("non existent".toHtmlTemplatePath().toFile()) - assertThrows(NoSuchFileException::class.java) { loader.getHtml("non existent") } - - // ensure that the result is not cached - assertThrows(NoSuchFileException::class.java) { loader.getHtml("non existent") } - - verify(exactly = 2) { fileSystem.readText("non existent".toHtmlTemplatePath()) } + assertThrows(NoSuchFileException::class.java) { loader.readHtml("non existent") } } @Test - fun `getHtml should throw IllegalArgumentException for path traversal attempts`() { + fun `readHtml should throw IllegalArgumentException for path traversal attempts`() { assertThrows(IllegalArgumentException::class.java) { - loader.getHtml("../traversed") + loader.readHtml("../traversed") } // while not necessary, here for explicitness diff --git a/src/test/resources/test-blog/posts/simple/cover.thumbnail.webp b/src/test/resources/test-blog/posts/simple/cover.thumbnail.webp deleted file mode 100644 index e69de29..0000000