libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

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:
M.gitignore | 1+
Mcommon/src/main/kotlin/TalerConfig.kt | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcommon/src/test/kotlin/ConfigTest.kt | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
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("")