libeufin

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

TalerConfig.kt (23494B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2023-2025 Taler Systems S.A.
      4  *
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9  *
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14  *
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 
     20 package tech.libeufin.common
     21 
     22 import kotlinx.serialization.json.Json
     23 import org.slf4j.Logger
     24 import org.slf4j.LoggerFactory
     25 import java.nio.file.*
     26 import java.time.*
     27 import java.time.format.*
     28 import java.time.temporal.ChronoUnit
     29 import kotlin.io.path.*
     30 
     31 private val logger: Logger = LoggerFactory.getLogger("libeufin-config")
     32 
     33 /** Config error when analyzing and using the taler configuration format */
     34 class TalerConfigError private constructor (m: String, cause: Throwable? = null) : Exception(m, cause) {
     35     companion object {
     36         /** Error when a specific option value is missing */
     37         internal fun missing(type: String, section: String, option: String): TalerConfigError =
     38             TalerConfigError("Missing $type option '$option' in section '$section'")
     39 
     40         /** Error when a specific option value is invalid */
     41         internal fun invalid(type: String, section: String, option: String, err: String): TalerConfigError =
     42             TalerConfigError("Expected $type option '$option' in section '$section': $err")
     43 
     44         /** Generic error not linked to a specific option value */
     45         fun generic(msg: String, cause: Throwable? = null): TalerConfigError =
     46             TalerConfigError(msg, cause)
     47     }
     48 }
     49 
     50 /** Configuration error when converting option value */
     51 class ValueError(val msg: String): Exception(msg)
     52 
     53 /** Information about how the configuration is loaded */
     54 data class ConfigSource(
     55     /** Name of the high-level project */
     56     val projectName: String = "taler",
     57     /** Name of the component within the package */
     58     val componentName: String = "taler",
     59     /**
     60      * Executable name that will be located on $PATH to
     61      * find the installation path of the package
     62      */
     63     val execName: String = "taler-config"
     64 ) {
     65     /** Load configuration from string */
     66     fun fromMem(content: String): TalerConfig {
     67         val loader = ConfigLoader(this)
     68         loader.loadDefaults()
     69         loader.loadFromMem(content.lineSequence(), null, 0)
     70         return loader.finalize()
     71     }
     72 
     73     /** 
     74      * Load configuration from [file], if [file] is null load from default configuration file 
     75      * 
     76      * The entry point for the default configuration will be the first file from this list:
     77      * - $XDG_CONFIG_HOME/$componentName.conf
     78      * - $HOME/.config/$componentName.conf
     79      * - /etc/$componentName.conf
     80      * - /etc/$projectName/$componentName.conf
     81      * */
     82     fun fromFile(file: Path?): TalerConfig {
     83         /** Search for the default configuration file path */
     84         fun defaultConfigPath(): Path? {
     85             return sequence {
     86                 val xdg = System.getenv("XDG_CONFIG_HOME")
     87                 if (xdg != null) yield(Path(xdg, "$componentName.conf"))
     88     
     89                 val home = System.getenv("HOME")
     90                 if (home != null) yield(Path(home, ".config/$componentName.conf"))
     91     
     92                 yield(Path("/etc/$componentName.conf"))
     93                 yield(Path("/etc/$projectName/$componentName.conf"))
     94             }.firstOrNull { it.exists() }
     95         }
     96         val path = file ?: defaultConfigPath()
     97         val loader = ConfigLoader(this)
     98         loader.loadDefaults()
     99         if (path != null) loader.loadFromFile(path, 0, 0)
    100         return loader.finalize()
    101     }
    102 
    103     /** Search for the binary installation path in PATH */
    104     fun installPath(): Path {
    105         val pathEnv = System.getenv("PATH")
    106         for (entry in pathEnv.splitToSequence(':')) {
    107             val path = Path(entry)
    108             if (path.resolve(execName).exists()) {
    109                 val parent = path.parent
    110                 if (parent != null) {
    111                     return parent.toRealPath()
    112                 }
    113             }
    114         }
    115         return Path("/usr")
    116     }
    117 }
    118 
    119 /**
    120  * Reader for Taler-style configuration files
    121  *
    122  * The configuration file format is similar to INI files
    123  * and fully described in the taler.conf man page.
    124  * 
    125  * @param source information about where to load configuration defaults from
    126  **/
    127 private class ConfigLoader(
    128     private val source: ConfigSource
    129 ) {
    130     private val sections: MutableMap<String, MutableMap<String, String>> = mutableMapOf()
    131 
    132     /**
    133      * Load configuration defaults from the file system
    134      * and populate the PATHS section based on the installation path.
    135      */
    136     fun loadDefaults() {
    137         val installDir = source.installPath()
    138 
    139         val section = sections.getOrPut("PATHS") { mutableMapOf() }
    140         section["PREFIX"] = "$installDir/"
    141         section["BINDIR"] = "$installDir/bin/"
    142         section["LIBEXECDIR"] = "$installDir/${source.projectName}/libexec/"
    143         section["DOCDIR"] = "$installDir/share/doc/${source.projectName}/"
    144         section["ICONDIR"] = "$installDir/share/icons/"
    145         section["LOCALEDIR"] = "$installDir/share/locale/"
    146         section["LIBDIR"] = "$installDir/lib/${source.projectName}/"
    147         section["DATADIR"] = "$installDir/share/${source.projectName}/"
    148         
    149         val baseConfigDir = installDir.resolve("share/${source.projectName}/config.d")
    150         try {
    151             baseConfigDir.useDirectoryEntries {
    152                 for (entry in it) {
    153                     loadFromFile(entry, 0, 0)
    154                 }
    155             }
    156         } catch (e: Exception) {
    157             when (e) {
    158                 is NotDirectoryException ->
    159                     logger.warn("Base config directory is not a directory")
    160                 is NoSuchFileException ->
    161                     logger.warn("Missing base config directory: $baseConfigDir")
    162                 else -> throw e
    163             }
    164         }
    165     }
    166 
    167     fun genericError(source: Path?, lineNum: Int, msg: String, cause: String? = null): TalerConfigError {
    168         val message = buildString {
    169             append(msg)
    170             append(" at '")
    171             if (source != null) {
    172                 append(source)
    173             } else {
    174                 append("mem")
    175             }
    176             append(':')
    177             append(lineNum)
    178             append('\'')
    179             if (cause != null) {
    180                 append(": ")
    181                 append(cause)
    182             }
    183         }
    184         return TalerConfigError.generic(message)
    185     }
    186 
    187     fun loadFromFile(file: Path, recursionDepth: Int, lineNum: Int) {
    188         if (recursionDepth > 128) {
    189             throw genericError(file, lineNum, "Recursion limit in config inlining")
    190         }
    191         logger.trace("load file at $file")
    192         return try {
    193             file.useLines {
    194                 loadFromMem(it, file, recursionDepth+1)
    195             }
    196         } catch (e: Exception) {
    197             when (e) {
    198                 is NoSuchFileException -> throw TalerConfigError.generic("Could not read config at '$file': no such file")
    199                 is AccessDeniedException -> throw TalerConfigError.generic("Could not read config at '$file': permission denied")
    200                 is TalerConfigError -> throw e
    201                 else -> throw TalerConfigError.generic("Could not read config at '$file'", e)
    202             }
    203         }
    204     }
    205 
    206     fun loadFromMem(lines: Sequence<String>, source: Path?, recursionDepth: Int) {
    207         var currentSection: MutableMap<String, String>? = null
    208         for ((lineNum, line) in lines.withIndex()) {
    209             if (RE_LINE_OR_COMMENT.matches(line)) {
    210                 continue
    211             }
    212 
    213             val directiveMatch = RE_DIRECTIVE.matchEntire(line)
    214             if (directiveMatch != null) {
    215                 if (source == null) throw genericError(source, lineNum, "Directives are only supported when loading from file")
    216                 val (directiveName, directiveArg) = directiveMatch.destructured
    217                 when (directiveName.lowercase()) {
    218                     "inline" -> loadFromFile(source.resolveSibling(directiveArg), recursionDepth, lineNum)
    219                     "inline-matching" -> {
    220                         try {
    221                             val pathMatcher = FileSystems.getDefault().getPathMatcher("glob:$directiveArg")
    222                             val entries = source.parent.walk()
    223                                 .filter { pathMatcher.matches(source.parent.relativize(it)) }
    224                             for (entry in entries) {
    225                                 loadFromFile(entry, recursionDepth, lineNum)
    226                             }
    227                         } catch (e: Exception) {
    228                             when (e) {
    229                                 is java.util.regex.PatternSyntaxException ->
    230                                     throw genericError(source, lineNum, "Malformed glob regex", e.message)
    231                                 else -> throw e
    232                             }
    233                         }
    234                     }
    235                     "inline-secret" -> {
    236                         val sp = directiveArg.split(" ")
    237                         if (sp.size != 2) {
    238                             throw genericError(source, lineNum, "invalid configuration, @inline-secret@ directive requires exactly two arguments")
    239                         }
    240                         val sectionName = sp[0]
    241                         val secretFilename = source.resolveSibling(sp[1])
    242 
    243                         if (!secretFilename.isReadable()) {
    244                             logger.warn("unable to read secrets from $secretFilename")
    245                         } else {
    246                             loadFromFile(secretFilename, recursionDepth, lineNum)
    247                         }
    248                     }
    249                     else -> throw genericError(source, lineNum, "unsupported directive '$directiveName'")
    250                 }
    251                 continue
    252             }
    253 
    254             val secMatch = RE_SECTION.matchEntire(line)
    255             if (secMatch != null) {
    256                 val (sectionName) = secMatch.destructured
    257                 currentSection = sections.getOrPut(sectionName.uppercase()) { mutableMapOf() }
    258                 continue
    259             } else if (currentSection == null) {
    260                 throw genericError(source, lineNum, "expected section header")
    261             }
    262 
    263             val paramMatch = RE_PARAM.matchEntire(line)
    264             if (paramMatch != null) {
    265                 var (optName, optVal) = paramMatch.destructured
    266                 if (optVal.length != 1 && optVal.startsWith('"') && optVal.endsWith('"')) {
    267                     optVal = optVal.substring(1, optVal.length - 1)
    268                 }
    269                 currentSection[optName.uppercase()] = optVal
    270                 continue
    271             }
    272             throw genericError(source, lineNum, "expected section header, option assignment or directive")
    273         }
    274     }
    275 
    276     fun finalize(): TalerConfig {
    277         return TalerConfig(sections)
    278     }
    279 
    280     companion object {
    281         private val RE_LINE_OR_COMMENT = Regex("^\\s*(#.*)?$")
    282         private val RE_SECTION = Regex("^\\s*\\[\\s*(.*)\\s*\\]\\s*$")
    283         private val RE_PARAM = Regex("^\\s*([^=]+?)\\s*=\\s*(.*?)\\s*$")
    284         private val RE_DIRECTIVE = Regex("^\\s*@([a-zA-Z-_]+)@\\s*(.*?)\\s*$")
    285     }
    286 }
    287 
    288 /** Taler-style configuration */
    289 class TalerConfig internal constructor(
    290     private val cfg: Map<String, Map<String, String>>
    291 ) {
    292     val sections: Set<String> get() = cfg.keys
    293 
    294     /** Create a string representation of the loaded configuration */
    295     fun stringify(): String = buildString {
    296         for ((section, options) in cfg) {
    297             appendLine("[$section]")
    298             for ((key, value) in options) {
    299                 appendLine("$key = $value")
    300             }
    301             appendLine()
    302         }
    303     }
    304 
    305     /**
    306      * Substitute ${...} and $... placeholders in a string
    307      * with values from the PATHS section in the
    308      * configuration and environment variables
    309      *
    310      * This substitution is typically only done for paths.
    311      */
    312     internal fun pathsub(str: String, recursionDepth: Int = 0): Path {
    313         /** Lookup for variable value from PATHS section in the configuration and environment variables */
    314         fun lookup(name: String, recursionDepth: Int = 0): Path? {
    315             val pathRes = section("PATHS").string(name).orNull()
    316             if (pathRes != null) {
    317                 return pathsub(pathRes, recursionDepth + 1)
    318             }
    319             val envVal = System.getenv(name)
    320             if (envVal != null) {
    321                 return Path(envVal)
    322             }
    323             return null
    324         }
    325 
    326         if (recursionDepth > 128) {
    327             throw ValueError("recursion limit in path substitution exceeded for '$str'")
    328         } else if (!str.contains('$')) { // Fast path without variables
    329             return Path(str)
    330         }
    331         
    332         var cursor = 0
    333         val result = StringBuilder()
    334         while (true) {
    335             // Look for next variable
    336             val dollarIndex = str.indexOf("$", cursor)
    337             if (dollarIndex == -1) {
    338                 // Reached end of string
    339                 result.append(str, cursor, str.length)
    340                 break
    341             }
    342 
    343             // Append normal characters
    344             result.append(str, cursor, dollarIndex)
    345             cursor = dollarIndex + 1
    346 
    347             // Check if variable is enclosed
    348             val enclosed = if (str[cursor] == '{') {
    349                 // ${var
    350                 cursor++
    351                 true
    352             } else false // $var
    353 
    354             // Extract variable name
    355             val startName = cursor
    356             while (cursor < str.length && (str[cursor].isLetterOrDigit() || str[cursor] == '_')) {
    357                 cursor++
    358             }
    359             val name = str.substring(startName, cursor)
    360 
    361             // Extract variable default if enclosed
    362             val default = if (!enclosed) null else {
    363                 if (str[cursor] == '}') { 
    364                     // ${var}
    365                     cursor++
    366                     null
    367                 } else if (cursor+1<str.length && str[cursor] == ':' && str[cursor+1] == '-') {
    368                     // ${var:-default}
    369                     cursor += 2
    370                     val startDefault = cursor
    371                     var depth = 1
    372                     // Find end of the ${...} expression
    373                     while (cursor < str.length && depth != 0) {
    374                         if (str[cursor] == '}') {
    375                             depth--
    376                         } else if (cursor+1<str.length && str[cursor] == '$' && str[cursor+1] == '{') {
    377                             depth++
    378                         }
    379                         cursor++
    380                     }
    381                     if (depth != 0)
    382                         throw ValueError("unbalanced variable expression '${str.substring(startDefault)}'")
    383                     str.substring(startDefault, cursor - 1)
    384                 } else {
    385                     throw ValueError("bad substitution '${str.substring(cursor-3)}'")
    386                 }
    387             }
    388             
    389             // Value resolution
    390             val resolved = lookup(name, recursionDepth + 1)
    391                 ?: default?.let { pathsub(it, recursionDepth + 1) }
    392                 ?: throw ValueError("unbound variable '$name'")
    393             result.append(resolved)
    394         }
    395         return Path(result.toString())
    396     }
    397 
    398     /** Access [section] */
    399     fun section(section: String): TalerConfigSection {
    400         val canonSection = section.uppercase()
    401         return TalerConfigSection(this, cfg[canonSection], section)
    402     }
    403 }
    404 
    405 /** Accessor/Converter for Taler-like configuration options */
    406 class TalerConfigOption<T> internal constructor(
    407     private val raw: String?,
    408     private val option: String,
    409     private val type: String,
    410     private val section: TalerConfigSection,
    411     private val transform: TalerConfigSection.(String) -> T,
    412 ) {
    413     /** Converted value or null if missing */
    414     fun orNull(): T? {
    415         if (raw == null) return null
    416         try {
    417             return section.transform(raw)
    418         } catch (e: ValueError) {
    419             throw TalerConfigError.invalid(type, section.section, option, e.msg)
    420         } catch (e: Exception) {
    421             throw TalerConfigError.invalid(type, section.section, option, e.message ?: e.toString())
    422         }
    423     }
    424     
    425     /** Converted value or null if missing, log a warning if missing */
    426     fun orNull(logger: Logger, warn: String): T? {
    427         val value = orNull()
    428         if (value == null) {
    429             val err = TalerConfigError.missing(type, section.section, option).message
    430             logger.warn("$err$warn")
    431         }
    432         return value
    433     }
    434 
    435     /** Converted value of default if missing */
    436     fun default(default: T): T = orNull() ?: default
    437 
    438     /** Converted value or default if missing, log a warning if missing */
    439     fun default(default: T, logger: Logger, warn: String): T = orNull(logger, warn) ?: default
    440 
    441     /** Converted value or throw if missing */
    442     fun require(): T = orNull() ?: throw TalerConfigError.missing(type, section.section, option)
    443 }
    444 
    445 /** Accessor/Converter for Taler-like configuration sections */
    446 class TalerConfigSection internal constructor(
    447     private val cfg: TalerConfig,
    448     private val entries: Map<String, String>?,
    449     val section: String
    450 ) {
    451     /** Setup an accessor/converted for a [type] at [option] using [transform] */
    452     fun <T> option(option: String, type: String, transform: TalerConfigSection.(String) -> T): TalerConfigOption<T> {
    453         val canonOption = option.uppercase()
    454         var raw = entries?.get(canonOption)
    455         if (raw == "") raw = null
    456         return TalerConfigOption(raw, option, type, this, transform)
    457     }
    458 
    459     /** Access [option] as String */
    460     fun string(option: String) = option(option, "string") { it }
    461 
    462     /** Access [option] as Regex */
    463     fun regex(option: String) = option(option, "regex") { Regex(it) }
    464 
    465     /** Access [option] as hexadecimal bytes */
    466     fun hex(option: String) = option(option, "hex") { 
    467         it.replace("\\s".toRegex(), "").decodeUpHex()
    468     }
    469 
    470     /** Access [option] as BaseURL */
    471     fun baseURL(option: String) = option(option, "baseURL") { BaseURL.parse(it) }
    472 
    473     /** Access [option] as Int */
    474     fun number(option: String) = option(option, "number") {
    475         it.toIntOrNull() ?: throw ValueError("'$it' not a valid number")
    476     }
    477 
    478     /** Access [option] as Boolean */
    479     fun boolean(option: String) = option(option, "boolean") {
    480         when (it.lowercase()) {
    481             "yes" -> true
    482             "no" -> false
    483             else -> throw ValueError("expected 'YES' or 'NO' got '$it'")
    484         }
    485     }
    486 
    487     /** Access [option] as Path */
    488     fun path(option: String) = option(option, "path") {
    489         cfg.pathsub(it)
    490     }
    491 
    492     /** Access [option] as Duration */
    493     fun duration(option: String) = option(option, "temporal") {
    494         if (!TEMPORAL_PATTERN.matches(it)) {
    495             throw ValueError("'$it' not a valid temporal")
    496         }
    497         TIME_AMOUNT_PATTERN.findAll(it).map { match ->
    498             val (rawAmount, unit) = match.destructured
    499             val amount = rawAmount.toLongOrNull() ?: throw ValueError("'$rawAmount' not a valid temporal amount")
    500             val value = when (unit) {
    501                 "us" -> 1
    502                 "ms" -> 1000
    503                 "s", "second", "seconds", "\"" -> 1000 * 1000L
    504                 "m", "min", "minute", "minutes", "'" -> 60 * 1000 * 1000L
    505                 "h", "hour", "hours" -> 60 * 60 * 1000 * 1000L
    506                 "d", "day", "days" -> 24 * 60 * 60 * 1000L * 1000L
    507                 "week", "weeks" ->  7 * 24 * 60 * 60 * 1000L * 1000L
    508                 "year", "years", "a" -> 31536000000000L
    509                 else -> throw ValueError("'$unit' not a valid temporal unit")
    510             }
    511             Duration.of(amount * value, ChronoUnit.MICROS)
    512         }.fold(Duration.ZERO) { a, b -> a.plus(b) }
    513     }
    514 
    515     /** Access [option] as Instant */
    516     fun date(option: String) = option(option, "date") {
    517         try {
    518             dateToInstant(it)
    519         } catch (e: DateTimeParseException) {
    520             val indexFmt = if (e.errorIndex != 0) " at index ${e.errorIndex}" else ""
    521             val causeMsg = e.cause?.message
    522             val causeFmt = if (causeMsg != null) ": ${causeMsg}" else ""
    523             throw ValueError("'$it' not a valid date$indexFmt$causeFmt")
    524         }
    525     }
    526 
    527     /** Access [option] as time */
    528     fun time(option: String) = option(option, "time") {
    529         try {
    530             LocalTime.parse(it, DateTimeFormatter.ISO_LOCAL_TIME)
    531         } catch (e: DateTimeParseException) {
    532             val indexFmt = if (e.errorIndex != 0) " at index ${e.errorIndex}" else ""
    533             val causeMsg = e.cause?.message
    534             val causeFmt = if (causeMsg != null) ": ${causeMsg}" else ""
    535             throw ValueError("'$it' not a valid time$indexFmt$causeFmt")
    536         }
    537     }
    538 
    539     /** Access [option] as JSON object [T] */
    540     inline fun <reified T> json(option: String, type: String) = option(option, type) {
    541         try {
    542             Json.decodeFromString<T>(it)
    543         } catch (e: Exception) {
    544             throw ValueError("'$it' is malformed")
    545         }
    546     }
    547 
    548     /** Access [option] as Map<String, String> */
    549     fun jsonMap(option: String) = json<Map<String, String>>(option, "json key/value map")
    550 
    551     /** Access [option] as TalerAmount */
    552     fun amount(option: String, currency: String) = option(option, "amount") {
    553         val amount = try {
    554             TalerAmount(it)
    555         } catch (e: CommonError) {
    556             throw ValueError("'$it' is malformed: ${e.message}")
    557         }
    558     
    559         if (amount.currency != currency) {
    560             throw ValueError("expected currency $currency got ${amount.currency}")
    561         }
    562         amount
    563     }
    564 
    565     /** Access a [type] at [option] using a custom [mapper] */
    566     fun <T> map(option: String, type: String, mapper: Map<String, T>) = option(option, type) {
    567         mapper[it] ?: throw ValueError("expected ${fmtEntriesChoice(mapper)} got '$it'")
    568     }
    569 
    570     /** Access a [type] at [option] using a custom [mapper] with code execution */
    571     fun <T> mapLambda(option: String, type: String, mapper: Map<String, () -> T>) = option(option, type) {
    572         mapper[it]?.invoke() ?: throw ValueError("expected ${fmtEntriesChoice(mapper)} got '$it'")
    573     }
    574     
    575     companion object {
    576         private val TIME_AMOUNT_PATTERN = Regex("([0-9]+) ?([a-z'\"]+)")
    577         private val TEMPORAL_PATTERN = Regex(" *([0-9]+ ?[a-z'\"]+ *)+")
    578         private val WHITESPACE_PATTERN = Regex("\\s")
    579 
    580         private fun <T> fmtEntriesChoice(mapper: Map<String, T>): String {
    581             return buildString {
    582                 val iter = mapper.keys.iterator()
    583                 var next = iter.next()
    584                 append("'$next'")
    585                 while (iter.hasNext()) {
    586                     next = iter.next()
    587                     if (iter.hasNext()) {
    588                         append(", '$next'")
    589                     } else {
    590                         append(" or '$next'")
    591                     }
    592                 }
    593             }
    594         }
    595     }
    596 }