commit 605aeff268fa30e66bb15c79fdccb9c325e2bb25
parent 1b685ec15207c5e138a71c2001dd2595f56af4b4
Author: Antoine A <>
Date: Thu, 4 Jul 2024 12:01:06 +0200
common: improve config parsing, error msg and tests
Diffstat:
3 files changed, 160 insertions(+), 51 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,6 +1,7 @@
.idea/*
.vscode
nexus/test
+common/tmp
testbench/test
testbench/config.json
configure
diff --git a/common/src/main/kotlin/TalerConfig.kt b/common/src/main/kotlin/TalerConfig.kt
@@ -21,9 +21,7 @@ package tech.libeufin.common
import org.slf4j.Logger
import org.slf4j.LoggerFactory
-import java.nio.file.AccessDeniedException
-import java.nio.file.NoSuchFileException
-import java.nio.file.Path
+import java.nio.file.*
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
@@ -37,7 +35,7 @@ import kotlinx.serialization.json.Json
private val logger: Logger = LoggerFactory.getLogger("libeufin-config")
/** Config error when analyzing and using the taler configuration format */
-class TalerConfigError private constructor (m: String) : Exception(m) {
+class TalerConfigError private constructor (m: String, cause: Throwable? = null) : Exception(m, cause) {
companion object {
/** Error when a specific option value is missing */
internal fun missing(type: String, section: String, option: String): TalerConfigError =
@@ -48,8 +46,8 @@ class TalerConfigError private constructor (m: String) : Exception(m) {
TalerConfigError("Expected $type option '$option' in section '$section': $err")
/** Generic error not linked to a specific option value */
- internal fun generic(msg: String): TalerConfigError =
- TalerConfigError(msg)
+ internal fun generic(msg: String, cause: Throwable? = null): TalerConfigError =
+ TalerConfigError(msg, cause)
}
}
@@ -72,7 +70,7 @@ data class ConfigSource(
fun fromMem(content: String): TalerConfig {
val loader = ConfigLoader(this)
loader.loadDefaults()
- loader.loadFromMem(content, null)
+ loader.loadFromMem(content.lineSequence(), null, 0)
return loader.finalize()
}
@@ -102,7 +100,7 @@ data class ConfigSource(
val path = file ?: defaultConfigPath()
val loader = ConfigLoader(this)
loader.loadDefaults()
- if (path != null) loader.loadFromFile(path)
+ if (path != null) loader.loadFromFile(path, 0, 0)
return loader.finalize()
}
@@ -153,63 +151,104 @@ private class ConfigLoader internal constructor(
section["DATADIR"] = "$installDir/share/${source.projectName}/"
val baseConfigDir = installDir.resolve("share/${source.projectName}/config.d")
- for (filePath in baseConfigDir.listDirectoryEntries()) {
- loadFromFile(filePath)
+ try {
+ baseConfigDir.useDirectoryEntries {
+ for (entry in it) {
+ loadFromFile(entry, 0, 0)
+ }
+ }
+ } catch (e: Exception) {
+ when (e) {
+ is NotDirectoryException ->
+ logger.warn("Base config directory is not a directory")
+ is NoSuchFileException ->
+ logger.warn("Missing base config directory: $baseConfigDir")
+ else -> throw e
+ }
}
}
- private fun loadFromGlob(source: Path, glob: String) {
- // TODO: Check that the Kotlin glob matches the glob from our spec
- for (entry in source.parent.listDirectoryEntries(glob)) {
- loadFromFile(entry)
+ fun genericError(source: Path?, lineNum: Int, msg: String, cause: String? = null): TalerConfigError {
+ val message = buildString {
+ append(msg)
+ append(" at '")
+ if (source != null) {
+ append(source)
+ } else {
+ append("mem")
+ }
+ append(':')
+ append(lineNum)
+ append('\'')
+ if (cause != null) {
+ append(": ")
+ append(cause)
+ }
}
+ return TalerConfigError.generic(message)
}
- private fun loadSecret(sectionName: String, path: Path) {
- if (!path.isReadable()) {
- logger.warn("unable to read secrets from $path")
- } else {
- loadFromFile(path)
+ internal fun loadFromFile(file: Path, recursionDepth: Int, lineNum: Int) {
+ if (recursionDepth > 128) {
+ throw genericError(file, lineNum, "Recursion limit in config inlining")
}
- }
-
- internal fun loadFromFile(file: Path) {
- val content = try {
- file.readText()
+ return try {
+ file.useLines {
+ loadFromMem(it, file, recursionDepth+1)
+ }
} catch (e: Exception) {
when (e) {
is NoSuchFileException -> throw TalerConfigError.generic("Could not read config at '$file': no such file")
is AccessDeniedException -> throw TalerConfigError.generic("Could not read config at '$file': permission denied")
- else -> throw Exception("Could not read config at '$file'", e)
+ is TalerConfigError -> throw e
+ else -> throw TalerConfigError.generic("Could not read config at '$file'", e)
}
}
- loadFromMem(content, file)
}
- internal fun loadFromMem(content: String, source: Path?) {
+ internal fun loadFromMem(lines: Sequence<String>, source: Path?, recursionDepth: Int) {
var currentSection: MutableMap<String, String>? = null
- for ((lineNum, line) in content.lines().withIndex()) {
+ for ((lineNum, line) in lines.withIndex()) {
if (RE_LINE_OR_COMMENT.matches(line)) {
continue
}
val directiveMatch = RE_DIRECTIVE.matchEntire(line)
if (directiveMatch != null) {
- if (source == null) throw TalerConfigError.generic("Directives are only supported when loading from file")
+ if (source == null) throw genericError(source, lineNum, "Directives are only supported when loading from file")
val (directiveName, directiveArg) = directiveMatch.destructured
when (directiveName.lowercase()) {
- "inline" -> loadFromFile(source.resolveSibling(directiveArg))
- "inline-matching" -> loadFromGlob(source, directiveArg)
+ "inline" -> loadFromFile(source.resolveSibling(directiveArg), recursionDepth, lineNum)
+ "inline-matching" -> {
+ try {
+ source.parent.useDirectoryEntries(directiveArg) {
+ for (entry in it) {
+ loadFromFile(entry, recursionDepth, lineNum)
+ }
+ }
+ } catch (e: Exception) {
+ when (e) {
+ is java.util.regex.PatternSyntaxException ->
+ throw genericError(source, lineNum, "Malformed glob regex", e.message)
+ else -> throw e
+ }
+ }
+ }
"inline-secret" -> {
val sp = directiveArg.split(" ")
if (sp.size != 2) {
- throw TalerConfigError.generic("invalid configuration, @inline-secret@ directive requires exactly two arguments")
+ throw genericError(source, lineNum, "invalid configuration, @inline-secret@ directive requires exactly two arguments")
}
val sectionName = sp[0]
val secretFilename = source.resolveSibling(sp[1])
- loadSecret(sectionName, secretFilename)
+
+ if (!secretFilename.isReadable()) {
+ logger.warn("unable to read secrets from $secretFilename")
+ } else {
+ loadFromFile(secretFilename, recursionDepth, lineNum)
+ }
}
- else -> throw TalerConfigError.generic("unsupported directive '$directiveName'")
+ else -> throw genericError(source, lineNum, "unsupported directive '$directiveName'")
}
continue
}
@@ -219,9 +258,8 @@ private class ConfigLoader internal constructor(
val (sectionName) = secMatch.destructured
currentSection = sections.getOrPut(sectionName.uppercase()) { mutableMapOf() }
continue
- }
- if (currentSection == null) {
- throw TalerConfigError.generic("section expected")
+ } else if (currentSection == null) {
+ throw genericError(source, lineNum, "expected section header")
}
val paramMatch = RE_PARAM.matchEntire(line)
@@ -233,7 +271,7 @@ private class ConfigLoader internal constructor(
currentSection[optName.uppercase()] = optVal
continue
}
- throw TalerConfigError.generic("expected section header, option assignment or directive in line $lineNum file ${source ?: "<input>"}")
+ throw genericError(source, lineNum, "expected section header, option assignment or directive")
}
}
diff --git a/common/src/test/kotlin/ConfigTest.kt b/common/src/test/kotlin/ConfigTest.kt
@@ -21,21 +21,91 @@ import org.junit.Test
import tech.libeufin.common.*
import tech.libeufin.common.db.*
import uk.org.webcompere.systemstubs.SystemStubs.*
-import kotlin.io.path.Path
+import com.github.ajalt.clikt.testing.*
+import java.io.ByteArrayOutputStream
+import java.io.PrintStream
+import kotlin.io.path.*
import kotlin.test.*
import java.time.Duration
import java.time.LocalDate
import java.time.temporal.ChronoUnit
class ConfigTest {
+ @Test
+ fun cli() {
+ val cmd = CliConfigCmd(ConfigSource("test", "test", "test"))
+ val configPath = Path("tmp/test-conf.conf")
+ val secondPath = Path("tmp/test-second-conf.conf")
+
+ fun testErr(msg: String) {
+ val prevOut = System.out
+ val tmpOut = ByteArrayOutputStream()
+ System.setOut(PrintStream(tmpOut))
+ val result = cmd.test("dump -c $configPath")
+ System.setOut(prevOut)
+ val tmpStr = tmpOut.toString(Charsets.UTF_8)
+ println(tmpStr)
+ assertEquals(1, result.statusCode)
+ val line = tmpStr.substringAfterLast(" -- ").trimEnd('\n')
+ println(line)
+ assertEquals(msg, line)
+ }
+
+ configPath.deleteIfExists()
+ testErr("Could not read config at '$configPath': no such file")
+
+ configPath.createParentDirectories()
+ configPath.createFile()
+ configPath.toFile().setReadable(false)
+ testErr("Could not read config at '$configPath': permission denied")
+
+ configPath.toFile().setReadable(true)
+ configPath.writeText("@inline@test-second-conf.conf")
+ secondPath.deleteIfExists()
+ testErr("Could not read config at '$secondPath': no such file")
+
+ secondPath.createFile()
+ secondPath.toFile().setReadable(false)
+ testErr("Could not read config at '$secondPath': permission denied")
+
+ configPath.writeText("@inline-matching@[*")
+ testErr("Malformed glob regex at '$configPath:0': Missing '] near index 1\n[*\n ^")
+
+ configPath.writeText("@inline-matching@*second-conf.conf")
+ testErr("Could not read config at '$secondPath': permission denied")
+
+ configPath.writeText("\n@inline-matching@*.conf")
+ testErr("Recursion limit in config inlining at '$configPath:1'")
+ configPath.writeText("\n\n@inline@test-conf.conf")
+ testErr("Recursion limit in config inlining at '$configPath:2'")
+ }
+
fun checkErr(msg: String, block: () -> Unit) {
val exception = assertFailsWith<TalerConfigError>(null, block)
+ println(exception.message)
assertEquals(msg, exception.message)
}
@Test
- fun error() {
- val conf = ConfigSource("libeufin", "libeufin-bank", "libeufin-bank").fromMem(
+ fun parsing() {
+ checkErr("expected section header at 'mem:1'") {
+ ConfigSource("test", "test", "test").fromMem(
+ """
+ key=value
+ """
+ )
+ }
+
+ checkErr("expected section header, option assignment or directive at 'mem:2'") {
+ ConfigSource("test", "test", "test").fromMem(
+ """
+ [section]
+ bad-line
+ """
+ )
+ }
+
+ ConfigSource("test", "test", "test").fromMem(
"""
[section-a]
@@ -48,16 +118,16 @@ class ConfigTest {
second_value = "test"
""".trimIndent()
- )
-
- // Missing section
- checkErr("Missing string option 'value' in section 'unknown'") {
- conf.section("unknown").string("value").require()
- }
+ ).let { conf ->
+ // Missing section
+ checkErr("Missing string option 'value' in section 'unknown'") {
+ conf.section("unknown").string("value").require()
+ }
- // Missing value
- checkErr("Missing string option 'value' in section 'section-a'") {
- conf.section("section-a").string("value").require()
+ // Missing value
+ checkErr("Missing string option 'value' in section 'section-a'") {
+ conf.section("section-a").string("value").require()
+ }
}
}
@@ -68,7 +138,7 @@ class ConfigTest {
malformed: List<Pair<List<String>, (String) -> String>>,
conf: String = ""
) {
- fun conf(content: String) = ConfigSource("libeufin", "libeufin-bank", "libeufin-bank").fromMem("$conf\n$content")
+ fun conf(content: String) = ConfigSource("test", "test", "test").fromMem("$conf\n$content")
// Check missing msg
val conf = conf("")