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 }