/*
* This file is part of LibEuFin.
* Copyright (C) 2023 Taler Systems S.A.
*
* LibEuFin is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
*
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
*
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
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 kotlin.io.path.*
private val logger: Logger = LoggerFactory.getLogger("libeufin-config")
private data class Section(
val entries: MutableMap,
)
private val reEmptyLine = Regex("^\\s*$")
private val reComment = Regex("^\\s*#.*$")
private val reSection = Regex("^\\s*\\[\\s*([^]]*)\\s*]\\s*$")
private val reParam = Regex("^\\s*([^=]+?)\\s*=\\s*(.*?)\\s*$")
private val reDirective = Regex("^\\s*@([a-zA-Z-_]+)@\\s*(.*?)\\s*$")
class TalerConfigError private constructor (m: String) : Exception(m) {
companion object {
fun missing(type: String, section: String, option: String): TalerConfigError =
TalerConfigError("Missing $type option '$option' in section '$section'")
fun invalid(type: String, section: String, option: String, err: String): TalerConfigError =
TalerConfigError("Expected $type option '$option' in section '$section': $err")
fun generic(msg: String): TalerConfigError =
TalerConfigError(msg)
}
}
/**
* Information about how the configuration is loaded.
*
* The entry point for the configuration will be the first file from this list:
* - /etc/$projectName/$componentName.conf
* - /etc/$componentName.conf
*/
data class ConfigSource(
/**
* Name of the high-level project.
*/
val projectName: String = "taler",
/**
* Name of the component within the package.
*/
val componentName: String = "taler",
/**
* Name of the binary that will be located on $PATH to
* find the installation path of the package.
*/
val installPathBinary: String = "taler-config",
)
fun ConfigSource.fromMem(content: String): TalerConfig {
val cfg = TalerConfig(this)
cfg.loadDefaults()
cfg.loadFromMem(content, null)
return cfg
}
fun ConfigSource.fromFile(file: Path?): TalerConfig {
val cfg = TalerConfig(this)
cfg.loadDefaults()
val path = file ?: cfg.findDefaultConfigFilename()
if (path != null) cfg.loadFromFile(path)
return cfg
}
/**
* Reader and writer for Taler-style configuration files.
*
* The configuration file format is similar to INI files
* and fully described in the taler.conf man page.
*
* @param configSource information about where to load configuration defaults from
*/
class TalerConfig internal constructor(
val configSource: ConfigSource,
) {
private val sectionMap: MutableMap = mutableMapOf()
private val componentName = configSource.componentName
private val projectName = configSource.projectName
private val installPathBinary = configSource.installPathBinary
val sections: Set get() = sectionMap.keys
/**
* Load configuration defaults from the file system
* and populate the PATHS section based on the installation path.
*/
internal fun loadDefaults() {
val installDir = getInstallPath()
val baseConfigDir = Path(installDir, "share/$projectName/config.d")
setSystemDefault("PATHS", "PREFIX", "$installDir/")
setSystemDefault("PATHS", "BINDIR", "$installDir/bin/")
setSystemDefault("PATHS", "LIBEXECDIR", "$installDir/$projectName/libexec/")
setSystemDefault("PATHS", "DOCDIR", "$installDir/share/doc/$projectName/")
setSystemDefault("PATHS", "ICONDIR", "$installDir/share/icons/")
setSystemDefault("PATHS", "LOCALEDIR", "$installDir/share/locale/")
setSystemDefault("PATHS", "LIBDIR", "$installDir/lib/$projectName/")
setSystemDefault("PATHS", "DATADIR", "$installDir/share/$projectName/")
for (filePath in baseConfigDir.listDirectoryEntries()) {
loadFromFile(filePath)
}
}
private fun loadFromGlob(source: Path, glob: String) {
// FIXME: Check that the Kotlin glob matches the glob from our spec
for (entry in source.parent.listDirectoryEntries(glob)) {
loadFromFile(entry)
}
}
private fun loadSecret(sectionName: String, secretFilename: Path) {
if (!secretFilename.isReadable()) {
logger.warn("unable to read secrets from $secretFilename")
} else {
loadFromFile(secretFilename)
}
}
internal fun loadFromFile(file: Path) {
val content = try {
file.readText()
} catch (e: Exception) {
when (e) {
is NoSuchFileException -> throw Exception("Could not read config at '$file': no such file")
is AccessDeniedException -> throw Exception("Could not read config at '$file': permission denied")
else -> throw Exception("Could not read config at '$file'", e)
}
}
loadFromMem(content, file)
}
internal fun loadFromMem(s: String, source: Path?) {
val lines = s.lines()
var lineNum = 0
var currentSection: String? = null
for (line in lines) {
lineNum++
if (reEmptyLine.matches(line)) {
continue
}
if (reComment.matches(line)) {
continue
}
val directiveMatch = reDirective.matchEntire(line)
if (directiveMatch != null) {
if (source == null) {
throw TalerConfigError.generic("Directives are only supported when loading from file")
}
val directiveName = directiveMatch.groups[1]!!.value.lowercase()
val directiveArg = directiveMatch.groups[2]!!.value
when (directiveName) {
"inline" -> {
val innerFilename = source.resolveSibling(directiveArg.trim())
loadFromFile(innerFilename)
}
"inline-matching" -> {
val glob = directiveArg.trim()
loadFromGlob(source, glob)
}
"inline-secret" -> {
val arg = directiveArg.trim()
val sp = arg.split(" ")
if (sp.size != 2) {
throw TalerConfigError.generic("invalid configuration, @inline-secret@ directive requires exactly two arguments")
}
val sectionName = sp[0]
val secretFilename = source.resolveSibling(sp[1])
loadSecret(sectionName, secretFilename)
}
else -> {
throw TalerConfigError.generic("unsupported directive '$directiveName'")
}
}
continue
}
val secMatch = reSection.matchEntire(line)
if (secMatch != null) {
currentSection = secMatch.groups[1]!!.value.uppercase()
continue
}
if (currentSection == null) {
throw TalerConfigError.generic("section expected")
}
val paramMatch = reParam.matchEntire(line)
if (paramMatch != null) {
val optName = paramMatch.groups[1]!!.value.uppercase()
var optVal = paramMatch.groups[2]!!.value
if (optVal.startsWith('"') && optVal.endsWith('"')) {
optVal = optVal.substring(1, optVal.length - 1)
}
val section = provideSection(currentSection)
section.entries[optName] = optVal
continue
}
throw TalerConfigError.generic("expected section header, option assignment or directive in line $lineNum file ${source ?: ""}")
}
}
private fun provideSection(name: String): Section {
val canonSecName = name.uppercase()
val existingSec = this.sectionMap[canonSecName]
if (existingSec != null) {
return existingSec
}
val newSection = Section(entries = mutableMapOf())
this.sectionMap[canonSecName] = newSection
return newSection
}
private fun setSystemDefault(section: String, option: String, value: String) {
// FIXME: The value should be marked as a system default for diagnostics pretty printing
val sec = provideSection(section)
sec.entries[option.uppercase()] = value
}
fun putValueString(section: String, option: String, value: String) {
val sec = provideSection(section)
sec.entries[option.uppercase()] = value
}
/**
* Create a string representation of the loaded configuration.
*/
fun stringify(): String {
val outStr = StringBuilder()
this.sectionMap.forEach { (sectionName, section) ->
var headerWritten = false
section.entries.forEach { (optionName, entry) ->
if (!headerWritten) {
outStr.appendLine("[$sectionName]")
headerWritten = true
}
outStr.appendLine("$optionName = $entry")
}
if (headerWritten) {
outStr.appendLine()
}
}
return outStr.toString()
}
private fun variableLookup(x: String, recursionDepth: Int = 0): Path? {
val pathRes = this.lookupString("PATHS", x)
if (pathRes != null) {
return pathsub(pathRes, recursionDepth + 1)
}
val envVal = System.getenv(x)
if (envVal != null) {
return Path(envVal)
}
return null
}
/**
* Substitute ${...} and $... placeholders in a string
* with values from the PATHS section in the
* configuration and environment variables
*
* This substitution is typically only done for paths.
*/
fun pathsub(x: String, recursionDepth: Int = 0): Path {
if (recursionDepth > 128) {
throw TalerConfigError.generic("recursion limit in path substitution exceeded")
}
val result = StringBuilder()
var l = 0
val s = x
while (l < s.length) {
if (s[l] != '$') {
// normal character
result.append(s[l])
l++
continue
}
if (l + 1 < s.length && s[l + 1] == '{') {
// ${var}
var depth = 1
val start = l
var p = start + 2
var hasDefault = false
var insideNamePath = true
// Find end of the ${...} expression
while (p < s.length) {
if (s[p] == '}') {
insideNamePath = false
depth--
} else if (s.length > p + 1 && s[p] == '$' && s[p + 1] == '{') {
depth++
insideNamePath = false
} else if (s.length > p + 1 && insideNamePath && s[p] == ':' && s[p + 1] == '-') {
hasDefault = true
}
p++
if (depth == 0) {
break
}
}
if (depth == 0) {
val inner = s.substring(start + 2, p - 1)
val varName: String
val varDefault: String?
if (hasDefault) {
val res = inner.split(":-", limit = 2)
varName = res[0]
varDefault = res[1]
} else {
varName = inner
varDefault = null
}
val r = variableLookup(varName, recursionDepth + 1)
if (r != null) {
result.append(r)
l = p
continue
} else if (varDefault != null) {
val resolvedDefault = pathsub(varDefault, recursionDepth + 1)
result.append(resolvedDefault)
l = p
continue
} else {
throw TalerConfigError.generic("malformed variable expression can't resolve variable '$varName'")
}
}
throw TalerConfigError.generic("malformed variable expression (unbalanced)")
} else {
// $var
var varEnd = l + 1
while (varEnd < s.length && (s[varEnd].isLetterOrDigit() || s[varEnd] == '_')) {
varEnd++
}
val varName = s.substring(l + 1, varEnd)
val res = variableLookup(varName)
if (res != null) {
result.append(res)
}
l = varEnd
}
}
return Path(result.toString())
}
/**
* Determine the filename of the default configuration file.
*
* If no such file can be found, return null.
*/
internal fun findDefaultConfigFilename(): Path? {
val xdg = System.getenv("XDG_CONFIG_HOME")
val home = System.getenv("HOME")
var filename: Path? = null
if (xdg != null) {
filename = Path(xdg, "$componentName.conf")
} else if (home != null) {
filename = Path(home, ".config/$componentName.conf")
}
if (filename != null && filename.exists()) {
return filename
}
val etc1 = Path("/etc/$componentName.conf")
if (etc1.exists()) {
return etc1
}
val etc2 = Path("/etc/$projectName/$componentName.conf")
if (etc2.exists()) {
return etc2
}
return null
}
/**
* Guess the path that the component was installed to.
*/
fun getInstallPath(): String {
// We use the location of the libeufin-bank
// binary to determine the installation prefix.
// If for some weird reason it's now found, we
// fall back to "/usr" as install prefix.
return getInstallPathFromBinary(installPathBinary)
}
private fun getInstallPathFromBinary(name: String): String {
val pathEnv = System.getenv("PATH")
val paths = pathEnv.split(":")
for (p in paths) {
val possiblePath = Path(p, name)
if (possiblePath.exists()) {
return Path(p, "..").toRealPath().toString()
}
}
return "/usr"
}
/* ----- Lookup ----- */
/**
* Look up a string value from the configuration.
*
* Return null if the value was not found in the configuration.
*/
fun lookupString(section: String, option: String): String? {
val canonSection = section.uppercase()
val canonOption = option.uppercase()
val str = this.sectionMap[canonSection]?.entries?.get(canonOption)
if (str == null) return null
if (str == "") return null
return str
}
fun requireString(section: String, option: String): String =
lookupString(section, option) ?: throw TalerConfigError.missing("string", section, option)
fun requireNumber(section: String, option: String): Int {
val raw = lookupString(section, option) ?: throw TalerConfigError.missing("number", section, option)
return raw.toIntOrNull() ?: throw TalerConfigError.invalid("number", section, option, "'$raw' not a valid number")
}
fun lookupBoolean(section: String, option: String): Boolean? {
val entry = lookupString(section, option) ?: return null
return when (val v = entry.lowercase()) {
"yes" -> true
"no" -> false
else -> throw TalerConfigError.invalid("yes/no", section, option, "got '$v'")
}
}
fun requireBoolean(section: String, option: String): Boolean =
lookupBoolean(section, option) ?: throw TalerConfigError.missing("boolean", section, option)
fun lookupPath(section: String, option: String): Path? {
val entry = lookupString(section, option) ?: return null
return pathsub(entry)
}
fun requirePath(section: String, option: String): Path =
lookupPath(section, option) ?: throw TalerConfigError.missing("path", section, option)
}