TalerConfig.kt (23549B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2023, 2024, 2025, 2026 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): String { 313 /** Lookup for variable value from PATHS section in the configuration and environment variables */ 314 fun lookup(name: String, recursionDepth: Int = 0): String? { 315 val pathRes = section("PATHS").string(name).orNull() 316 if (pathRes != null) { 317 return pathsub(pathRes, recursionDepth + 1) 318 } 319 return System.getenv(name) 320 } 321 322 if (recursionDepth > 128) { 323 throw ValueError("recursion limit in path substitution exceeded for '$str'") 324 } else if (!str.contains('$')) { // Fast path without variables 325 return str 326 } 327 328 var cursor = 0 329 val result = StringBuilder() 330 while (true) { 331 // Look for next variable 332 val dollarIndex = str.indexOf("$", cursor) 333 if (dollarIndex == -1) { 334 // Reached end of string 335 result.append(str, cursor, str.length) 336 break 337 } 338 339 // Append normal characters 340 result.append(str, cursor, dollarIndex) 341 cursor = dollarIndex + 1 342 343 // Check if variable is enclosed 344 val enclosed = if (str[cursor] == '{') { 345 // ${var 346 cursor++ 347 true 348 } else false // $var 349 350 // Extract variable name 351 val startName = cursor 352 while (cursor < str.length && (str[cursor].isLetterOrDigit() || str[cursor] == '_')) { 353 cursor++ 354 } 355 val name = str.substring(startName, cursor) 356 357 // Extract variable default if enclosed 358 val default = if (!enclosed) null else { 359 if (str[cursor] == '}') { 360 // ${var} 361 cursor++ 362 null 363 } else if (cursor+1<str.length && str[cursor] == ':' && str[cursor+1] == '-') { 364 // ${var:-default} 365 cursor += 2 366 val startDefault = cursor 367 var depth = 1 368 // Find end of the ${...} expression 369 while (cursor < str.length && depth != 0) { 370 if (str[cursor] == '}') { 371 depth-- 372 } else if (cursor+1<str.length && str[cursor] == '$' && str[cursor+1] == '{') { 373 depth++ 374 } 375 cursor++ 376 } 377 if (depth != 0) 378 throw ValueError("unbalanced variable expression '${str.substring(startDefault)}'") 379 str.substring(startDefault, cursor - 1) 380 } else { 381 throw ValueError("bad substitution '${str.substring(cursor-3)}'") 382 } 383 } 384 385 // Value resolution 386 val resolved = lookup(name, recursionDepth + 1) 387 ?: default?.let { pathsub(it, recursionDepth + 1) } 388 ?: throw ValueError("unbound variable '$name'") 389 result.append(resolved) 390 } 391 return result.toString() 392 } 393 394 /** Access [section] */ 395 fun section(section: String): TalerConfigSection { 396 val canonSection = section.uppercase() 397 return TalerConfigSection(this, cfg[canonSection], section) 398 } 399 } 400 401 /** Accessor/Converter for Taler-like configuration options */ 402 class TalerConfigOption<T> internal constructor( 403 private val raw: String?, 404 private val option: String, 405 private val type: String, 406 private val section: TalerConfigSection, 407 private val transform: TalerConfigSection.(String) -> T, 408 ) { 409 /** Converted value or null if missing */ 410 fun orNull(): T? { 411 if (raw == null) return null 412 try { 413 return section.transform(raw) 414 } catch (e: ValueError) { 415 throw TalerConfigError.invalid(type, section.section, option, e.msg) 416 } catch (e: Exception) { 417 throw TalerConfigError.invalid(type, section.section, option, e.message ?: e.toString()) 418 } 419 } 420 421 /** Converted value or null if missing, log a warning if missing */ 422 fun orNull(logger: Logger, warn: String): T? { 423 val value = orNull() 424 if (value == null) { 425 val err = TalerConfigError.missing(type, section.section, option).message 426 logger.warn("$err$warn") 427 } 428 return value 429 } 430 431 /** Converted value of default if missing */ 432 fun default(default: T): T = orNull() ?: default 433 434 /** Converted value or default if missing, log a warning if missing */ 435 fun default(default: T, logger: Logger, warn: String): T = orNull(logger, warn) ?: default 436 437 /** Converted value or throw if missing */ 438 fun require(): T = orNull() ?: throw TalerConfigError.missing(type, section.section, option) 439 } 440 441 /** Accessor/Converter for Taler-like configuration sections */ 442 class TalerConfigSection internal constructor( 443 private val cfg: TalerConfig, 444 private val entries: Map<String, String>?, 445 val section: String 446 ) { 447 /** Setup an accessor/converted for a [type] at [option] using [transform] */ 448 fun <T> option(option: String, type: String, transform: TalerConfigSection.(String) -> T): TalerConfigOption<T> { 449 val canonOption = option.uppercase() 450 var raw = entries?.get(canonOption) 451 if (raw == "") raw = null 452 return TalerConfigOption(raw, option, type, this, transform) 453 } 454 455 /** Access [option] as String */ 456 fun string(option: String) = option(option, "string") { it } 457 458 /** Access [option] as String with variable substitution */ 459 fun stringsub(option: String) = option(option, "string") { 460 cfg.pathsub(it) 461 } 462 463 /** Access [option] as Regex */ 464 fun regex(option: String) = option(option, "regex") { Regex(it) } 465 466 /** Access [option] as hexadecimal bytes */ 467 fun hex(option: String) = option(option, "hex") { 468 it.replace("\\s".toRegex(), "").decodeUpHex() 469 } 470 471 /** Access [option] as BaseURL */ 472 fun baseURL(option: String) = option(option, "baseURL") { BaseURL.parse(it) } 473 474 /** Access [option] as Int */ 475 fun number(option: String) = option(option, "number") { 476 it.toIntOrNull() ?: throw ValueError("'$it' not a valid number") 477 } 478 479 /** Access [option] as Boolean */ 480 fun boolean(option: String) = option(option, "boolean") { 481 when (it.lowercase()) { 482 "yes" -> true 483 "no" -> false 484 else -> throw ValueError("expected 'YES' or 'NO' got '$it'") 485 } 486 } 487 488 /** Access [option] as Path */ 489 fun path(option: String) = option(option, "path") { 490 Path(cfg.pathsub(it)) 491 } 492 493 /** Access [option] as Duration */ 494 fun duration(option: String) = option(option, "temporal") { 495 if (!TEMPORAL_PATTERN.matches(it)) { 496 throw ValueError("'$it' not a valid temporal") 497 } 498 TIME_AMOUNT_PATTERN.findAll(it).map { match -> 499 val (rawAmount, unit) = match.destructured 500 val amount = rawAmount.toLongOrNull() ?: throw ValueError("'$rawAmount' not a valid temporal amount") 501 val value = when (unit) { 502 "us" -> 1 503 "ms" -> 1000 504 "s", "second", "seconds", "\"" -> 1000 * 1000L 505 "m", "min", "minute", "minutes", "'" -> 60 * 1000 * 1000L 506 "h", "hour", "hours" -> 60 * 60 * 1000 * 1000L 507 "d", "day", "days" -> 24 * 60 * 60 * 1000L * 1000L 508 "week", "weeks" -> 7 * 24 * 60 * 60 * 1000L * 1000L 509 "year", "years", "a" -> 31536000000000L 510 else -> throw ValueError("'$unit' not a valid temporal unit") 511 } 512 Duration.of(amount * value, ChronoUnit.MICROS) 513 }.fold(Duration.ZERO) { a, b -> a.plus(b) } 514 } 515 516 /** Access [option] as Instant */ 517 fun date(option: String) = option(option, "date") { 518 try { 519 dateToInstant(it) 520 } catch (e: DateTimeParseException) { 521 val indexFmt = if (e.errorIndex != 0) " at index ${e.errorIndex}" else "" 522 val causeMsg = e.cause?.message 523 val causeFmt = if (causeMsg != null) ": ${causeMsg}" else "" 524 throw ValueError("'$it' not a valid date$indexFmt$causeFmt") 525 } 526 } 527 528 /** Access [option] as time */ 529 fun time(option: String) = option(option, "time") { 530 try { 531 LocalTime.parse(it, DateTimeFormatter.ISO_LOCAL_TIME) 532 } catch (e: DateTimeParseException) { 533 val indexFmt = if (e.errorIndex != 0) " at index ${e.errorIndex}" else "" 534 val causeMsg = e.cause?.message 535 val causeFmt = if (causeMsg != null) ": ${causeMsg}" else "" 536 throw ValueError("'$it' not a valid time$indexFmt$causeFmt") 537 } 538 } 539 540 /** Access [option] as JSON object [T] */ 541 inline fun <reified T> json(option: String, type: String) = option(option, type) { 542 try { 543 Json.decodeFromString<T>(it) 544 } catch (e: Exception) { 545 throw ValueError("'$it' is malformed") 546 } 547 } 548 549 /** Access [option] as Map<String, String> */ 550 fun jsonMap(option: String) = json<Map<String, String>>(option, "json key/value map") 551 552 /** Access [option] as TalerAmount */ 553 fun amount(option: String, currency: String) = option(option, "amount") { 554 val amount = try { 555 TalerAmount(it) 556 } catch (e: CommonError) { 557 throw ValueError("'$it' is malformed: ${e.message}") 558 } 559 560 if (amount.currency != currency) { 561 throw ValueError("expected currency $currency got ${amount.currency}") 562 } 563 amount 564 } 565 566 /** Access a [type] at [option] using a custom [mapper] */ 567 fun <T> map(option: String, type: String, mapper: Map<String, T>) = option(option, type) { 568 mapper[it] ?: throw ValueError("expected ${fmtEntriesChoice(mapper)} got '$it'") 569 } 570 571 /** Access a [type] at [option] using a custom [mapper] with code execution */ 572 fun <T> mapLambda(option: String, type: String, mapper: Map<String, () -> T>) = option(option, type) { 573 mapper[it]?.invoke() ?: throw ValueError("expected ${fmtEntriesChoice(mapper)} got '$it'") 574 } 575 576 companion object { 577 private val TIME_AMOUNT_PATTERN = Regex("([0-9]+) ?([a-z'\"]+)") 578 private val TEMPORAL_PATTERN = Regex(" *([0-9]+ ?[a-z'\"]+ *)+") 579 private val WHITESPACE_PATTERN = Regex("\\s") 580 581 private fun <T> fmtEntriesChoice(mapper: Map<String, T>): String { 582 return buildString { 583 val iter = mapper.keys.iterator() 584 var next = iter.next() 585 append("'$next'") 586 while (iter.hasNext()) { 587 next = iter.next() 588 if (iter.hasNext()) { 589 append(", '$next'") 590 } else { 591 append(" or '$next'") 592 } 593 } 594 } 595 } 596 } 597 }