/*
This file is part of GNU Taler
(C) 2020-2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler 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 General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see
*/
/**
* Utilities to handle Taler-style configuration files.
*
* @author Florian Dold
*/
/**
* Imports
*/
import { AmountJson, Amounts } from "./amounts.js";
import { Logger } from "./logging.js";
import nodejs_fs from "fs";
import nodejs_os from "os";
import nodejs_path from "path";
const logger = new Logger("talerconfig.ts");
export class ConfigError extends Error {
constructor(message: string) {
super();
Object.setPrototypeOf(this, ConfigError.prototype);
this.name = "ConfigError";
this.message = message;
}
}
enum EntryOrigin {
/**
* From a default file.
*/
DefaultFile = 1,
/**
* From a system/installation specific default value.
*/
DefaultSystem = 2,
/**
* Loaded from file or string
*/
Loaded = 3,
/**
* Changed after loading
*/
Changed = 4,
}
interface Entry {
value: string;
sourceLine: number;
sourceFile: string;
origin: EntryOrigin;
}
interface Section {
secretFilename?: string;
inaccessible: boolean;
entries: { [optionName: string]: Entry };
}
type SectionMap = { [sectionName: string]: Section };
/**
* Different projects use the GNUnet/Taler-Style config.
*
* The config source determines where to locate the configuration.
*/
export interface ConfigSource {
projectName: string;
componentName: string;
installPathBinary: string;
baseConfigVarname: string;
prefixVarname: string;
}
export type ConfigSourceDef = { [x: string]: ConfigSource | undefined };
export const ConfigSources = {
["taler"]: {
projectName: "taler",
componentName: "taler",
installPathBinary: "taler-config",
baseConfigVarname: "TALER_BASE_CONFIG",
prefixVarname: "TALER_PREFIX",
} satisfies ConfigSource,
["libeufin-bank"]: {
projectName: "libeufin",
componentName: "libeufin-bank",
installPathBinary: "libeufin-bank",
baseConfigVarname: "LIBEUFIN_BASE_CONFIG",
prefixVarname: "LIBEUFIN_PREFIX",
} satisfies ConfigSource,
["libeufin-nexus"]: {
projectName: "libeufin",
componentName: "libeufin-nexus",
installPathBinary: "libeufin-nexus",
baseConfigVarname: "LIBEUFIN_BASE_CONFIG",
prefixVarname: "LIBEUFIN_PREFIX",
} satisfies ConfigSource,
["gnunet"]: {
projectName: "gnunet",
componentName: "gnunet",
installPathBinary: "gnunet-config",
baseConfigVarname: "GNUNET_BASE_CONFIG",
prefixVarname: "GNUNET_PREFIX",
} satisfies ConfigSource,
} satisfies ConfigSourceDef;
const defaultConfigSource: ConfigSource = ConfigSources.taler;
export class ConfigValue {
constructor(
private sectionName: string,
private optionName: string,
private value: string | undefined,
private converter: (x: string) => T,
) {}
required(): T {
if (this.value == undefined) {
throw new ConfigError(
`required option [${this.sectionName}]/${this.optionName} not found`,
);
}
return this.converter(this.value);
}
orUndefined(): T | undefined {
if (this.value !== undefined) {
return this.converter(this.value);
} else {
return undefined;
}
}
orDefault(v: T): T | undefined {
if (this.value !== undefined) {
return this.converter(this.value);
} else {
return v;
}
}
isDefined(): boolean {
return this.value !== undefined;
}
getValue(): string | undefined {
return this.value;
}
}
/**
* Expand a path by resolving the tilde syntax for home directories
* and by making relative paths absolute based on the current working directory.
*/
export function expandPath(path: string): string {
if (path[0] === "~") {
path = nodejs_path.join(nodejs_os.homedir(), path.slice(1));
}
if (path[0] !== "/") {
path = nodejs_path.join(process.cwd(), path);
}
return path;
}
/**
* Shell-style path substitution.
*
* Supported patterns:
* "$x" (look up "x")
* "${x}" (look up "x")
* "${x:-y}" (look up "x", fall back to expanded y)
*/
export function pathsub(
x: string,
lookup: (s: string, depth: number) => string | undefined,
recursionDepth = 0,
): string {
if (recursionDepth >= 128) {
throw Error("recursion in path substitution");
}
let s = x;
let l = 0;
while (l < s.length) {
if (s[l] === "$") {
if (s[l + 1] === "{") {
let depth = 1;
const start = l;
let p = start + 2;
let insideNamePart = true;
let hasDefault = false;
for (; p < s.length; p++) {
if (s[p] == "}") {
insideNamePart = false;
depth--;
} else if (s[p] === "$" && s[p + 1] === "{") {
insideNamePart = false;
depth++;
}
if (insideNamePart && s[p] === ":" && s[p + 1] === "-") {
hasDefault = true;
}
if (depth == 0) {
break;
}
}
if (depth == 0) {
const inner = s.slice(start + 2, p);
let varname: string;
let defaultValue: string | undefined;
if (hasDefault) {
[varname, defaultValue] = inner.split(":-", 2);
} else {
varname = inner;
defaultValue = undefined;
}
const r = lookup(varname, depth + 1);
if (r !== undefined) {
s = s.substring(0, start) + r + s.substring(p + 1);
l = start + r.length;
continue;
} else if (defaultValue !== undefined) {
const resolvedDefault = pathsub(defaultValue, lookup, depth + 1);
s = s.substring(0, start) + resolvedDefault + s.substring(p + 1);
l = start + resolvedDefault.length;
continue;
}
}
l = p;
continue;
} else {
const m = /^[a-zA-Z-_][a-zA-Z0-9-_]*/.exec(s.substring(l + 1));
if (m && m[0]) {
const r = lookup(m[0], recursionDepth + 1);
if (r !== undefined) {
s = s.substring(0, l) + r + s.substring(l + 1 + m[0].length);
l = l + r.length;
continue;
}
}
}
}
l++;
}
return s;
}
interface LoadOptions {
filename?: string;
banDirectives?: boolean;
}
export interface StringifyOptions {
diagnostics?: boolean;
excludeDefaults?: boolean;
}
export interface LoadedFile {
filename: string;
level: number;
}
/**
* Check for a simple wildcard match.
* Only asterisks are allowed.
* Asterisks match everything, including slashes.
*
* @param pattern pattern with wildcards
* @param str string to match against
* @returns true on match, false otherwise
*/
function globMatch(pattern: string, str: string): boolean {
/* Position in the input string */
let strPos = 0;
/* Position in the pattern */
let patPos = 0;
/* Backtrack position in string */
let strBt = -1;
/* Backtrack position in pattern */
let patBt = -1;
for (;;) {
if (pattern[patPos] === "*") {
strBt = strPos;
patBt = patPos++;
} else if (patPos === pattern.length && strPos === str.length) {
return true;
} else if (pattern[patPos] === str[strPos]) {
strPos++;
patPos++;
} else {
if (patBt < 0) {
return false;
}
strPos = strBt + 1;
if (strPos >= str.length) {
return false;
}
patPos = patBt;
}
}
}
function normalizeInlineFilename(parentFile: string, f: string): string {
if (f[0] === "/") {
return f;
}
const resolvedParentDir = nodejs_path.dirname(
nodejs_fs.realpathSync(parentFile),
);
return nodejs_path.join(resolvedParentDir, f);
}
/**
* Crude implementation of the which(1) shell command.
*
* Tries to locate the location of an executable based on the
* "PATH" environment variable.
*/
function which(name: string): string | undefined {
const paths = process.env["PATH"]?.split(":");
if (!paths) {
return undefined;
}
for (const path of paths) {
const filename = nodejs_path.join(path, name);
if (nodejs_fs.existsSync(filename)) {
return filename;
}
}
return undefined;
}
export class Configuration {
private sectionMap: SectionMap = {};
private hintEntrypoint: string | undefined;
private loadedFiles: LoadedFile[] = [];
private nestLevel = 0;
/**
* Does the entrypoint config file contain complex
* directives?
*/
private entrypointIsComplex: boolean = false;
constructor(private configSource: ConfigSource = defaultConfigSource) {}
private loadFromFilename(
filename: string,
isDefaultSource: boolean,
opts: LoadOptions = {},
): void {
filename = expandPath(filename);
const checkCycle = () => {
let level = this.nestLevel;
const fns = [...this.loadedFiles].reverse();
for (const lf of fns) {
if (lf.level >= level) {
continue;
}
level = lf.level;
if (lf.filename === filename) {
throw Error(`cyclic inline ${lf.filename} -> ${filename}`);
}
}
};
checkCycle();
const s = nodejs_fs.readFileSync(filename, "utf-8");
this.loadedFiles.push({
filename: filename,
level: this.nestLevel,
});
const oldNestLevel = this.nestLevel;
this.nestLevel += 1;
try {
this.internalLoadFromString(s, isDefaultSource, {
...opts,
filename: filename,
});
} finally {
this.nestLevel = oldNestLevel;
}
}
private loadGlob(
parentFilename: string,
isDefaultSource: boolean,
fileglob: string,
): void {
const resolvedParent = nodejs_fs.realpathSync(parentFilename);
const parentDir = nodejs_path.dirname(resolvedParent);
let fullFileglob: string;
if (fileglob.startsWith("/")) {
fullFileglob = fileglob;
} else {
fullFileglob = nodejs_path.join(parentDir, fileglob);
}
fullFileglob = expandPath(fullFileglob);
const head = nodejs_path.dirname(fullFileglob);
const tail = nodejs_path.basename(fullFileglob);
const files = nodejs_fs.readdirSync(head);
for (const f of files) {
if (globMatch(tail, f)) {
const fullPath = nodejs_path.join(head, f);
this.loadFromFilename(fullPath, isDefaultSource);
}
}
}
private loadSecret(
sectionName: string,
filename: string,
isDefaultSource: boolean,
): void {
const sec = this.provideSection(sectionName);
sec.secretFilename = filename;
const otherCfg = new Configuration();
try {
nodejs_fs.accessSync(filename, nodejs_fs.constants.R_OK);
} catch (err) {
sec.inaccessible = true;
return;
}
otherCfg.loadFromFilename(filename, isDefaultSource, {
banDirectives: true,
});
const otherSec = otherCfg.provideSection(sectionName);
for (const opt of Object.keys(otherSec.entries)) {
this.setString(sectionName, opt, otherSec.entries[opt].value);
}
}
private internalLoadFromString(
s: string,
isDefaultSource: boolean,
opts: LoadOptions = {},
): void {
let lineNo = 0;
const fn = opts.filename ?? "";
const reComment = /^\s*#.*$/;
const reSection = /^\s*\[\s*([^\]]*)\s*\]\s*$/;
const reParam = /^\s*([^=]+?)\s*=\s*(.*?)\s*$/;
const reDirective = /^\s*@([a-zA-Z-_]+)@\s*(.*?)\s*$/;
const reEmptyLine = /^\s*$/;
let currentSection: string | undefined = undefined;
const lines = s.split("\n");
for (const line of lines) {
lineNo++;
if (reEmptyLine.test(line)) {
continue;
}
if (reComment.test(line)) {
continue;
}
const directiveMatch = line.match(reDirective);
if (directiveMatch) {
if (opts.banDirectives) {
throw Error(
`invalid configuration, directive in ${fn}:${lineNo} forbidden`,
);
}
if (!isDefaultSource) {
this.entrypointIsComplex = true;
}
const directive = directiveMatch[1].toLowerCase();
switch (directive) {
case "inline": {
if (!opts.filename) {
throw Error(
`invalid configuration, @inline-matching@ directive in ${fn}:${lineNo} can only be used from a file`,
);
}
const arg = directiveMatch[2].trim();
this.loadFromFilename(
normalizeInlineFilename(opts.filename, arg),
isDefaultSource,
);
break;
}
case "inline-secret": {
if (!opts.filename) {
throw Error(
`invalid configuration, @inline-matching@ directive in ${fn}:${lineNo} can only be used from a file`,
);
}
const arg = directiveMatch[2].trim();
const sp = arg.split(" ").map((x) => x.trim());
if (sp.length != 2) {
throw Error(
`invalid configuration, @inline-secret@ directive in ${fn}:${lineNo} requires two arguments`,
);
}
const secretFilename = normalizeInlineFilename(
opts.filename,
sp[1],
);
this.loadSecret(sp[0], secretFilename, isDefaultSource);
break;
}
case "inline-matching": {
const arg = directiveMatch[2].trim();
if (!opts.filename) {
throw Error(
`invalid configuration, @inline-matching@ directive in ${fn}:${lineNo} can only be used from a file`,
);
}
this.loadGlob(opts.filename, isDefaultSource, arg);
break;
}
default:
throw Error(
`invalid configuration, unsupported directive in ${fn}:${lineNo}`,
);
}
continue;
}
const secMatch = line.match(reSection);
if (secMatch) {
currentSection = secMatch[1];
continue;
}
if (currentSection === undefined) {
throw Error(
`invalid configuration, expected section header in ${fn}:${lineNo}`,
);
}
currentSection = currentSection.toUpperCase();
const paramMatch = line.match(reParam);
if (paramMatch) {
const optName = paramMatch[1].toUpperCase();
let val = paramMatch[2];
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, val.length - 1);
}
const sec = this.provideSection(currentSection);
sec.entries[optName] = {
value: val,
sourceFile: opts.filename ?? "",
sourceLine: lineNo,
origin: isDefaultSource
? EntryOrigin.DefaultFile
: EntryOrigin.Loaded,
};
continue;
}
throw Error(
`invalid configuration, expected section header, option assignment or directive in ${fn}:${lineNo}`,
);
}
}
private provideSection(section: string): Section {
const secNorm = section.toUpperCase();
if (this.sectionMap[secNorm]) {
return this.sectionMap[secNorm];
}
const newSec: Section = {
entries: {},
inaccessible: false,
};
this.sectionMap[secNorm] = newSec;
return newSec;
}
private findEntry(section: string, option: string): Entry | undefined {
const secNorm = section.toUpperCase();
const optNorm = option.toUpperCase();
return this.sectionMap[secNorm]?.entries[optNorm];
}
setString(section: string, option: string, value: string): void {
const sec = this.provideSection(section);
sec.entries[option.toUpperCase()] = {
value,
sourceLine: 0,
sourceFile: "",
origin: EntryOrigin.Changed,
};
}
/**
* Set a string value to a value from default locations.
*/
private setStringSystemDefault(
section: string,
option: string,
value: string,
): void {
const sec = this.provideSection(section);
sec.entries[option.toUpperCase()] = {
value,
sourceLine: 0,
sourceFile: "",
origin: EntryOrigin.DefaultSystem,
};
}
/**
* Get upper-cased section names.
*/
getSectionNames(): string[] {
return Object.keys(this.sectionMap).map((x) => x.toUpperCase());
}
getString(section: string, option: string): ConfigValue {
const secNorm = section.toUpperCase();
const optNorm = option.toUpperCase();
const val = this.findEntry(secNorm, optNorm)?.value;
return new ConfigValue(secNorm, optNorm, val, (x) => x);
}
getPath(section: string, option: string): ConfigValue {
const secNorm = section.toUpperCase();
const optNorm = option.toUpperCase();
const val = this.findEntry(secNorm, optNorm)?.value;
return new ConfigValue(secNorm, optNorm, val, (x) =>
pathsub(x, (v, d) => this.lookupVariable(v, d + 1)),
);
}
getYesNo(section: string, option: string): ConfigValue {
const secNorm = section.toUpperCase();
const optNorm = option.toUpperCase();
const val = this.findEntry(secNorm, optNorm)?.value;
const convert = (x: string): boolean => {
x = x.toLowerCase();
if (x === "yes") {
return true;
} else if (x === "no") {
return false;
}
throw Error(
`invalid config value for [${secNorm}]/${optNorm}, expected yes/no`,
);
};
return new ConfigValue(secNorm, optNorm, val, convert);
}
getNumber(section: string, option: string): ConfigValue {
const secNorm = section.toUpperCase();
const optNorm = option.toUpperCase();
const val = this.findEntry(secNorm, optNorm)?.value;
const convert = (x: string): number => {
try {
return Number.parseInt(x, 10);
} catch (e) {
throw Error(
`invalid config value for [${secNorm}]/${optNorm}, expected number`,
);
}
};
return new ConfigValue(secNorm, optNorm, val, convert);
}
lookupVariable(x: string, depth: number = 0): string | undefined {
// We loop up options in PATHS in upper case, as option names
// are case insensitive
const val = this.findEntry("PATHS", x)?.value;
if (val !== undefined) {
return pathsub(val, (v, d) => this.lookupVariable(v, d), depth);
}
// Environment variables can be case sensitive, respect that.
const envVal = process.env[x];
if (envVal !== undefined) {
return envVal;
}
logger.warn(`unable to resolve variable '${x}'`);
return;
}
getAmount(section: string, option: string): ConfigValue {
const val = this.findEntry(section, option)?.value;
return new ConfigValue(section, option, val, (x) =>
Amounts.parseOrThrow(x),
);
}
private loadDefaultsFromDir(dirname: string): void {
const files = nodejs_fs.readdirSync(dirname);
for (const f of files) {
const fn = nodejs_path.join(dirname, f);
this.loadFromFilename(fn, true);
}
}
private loadDefaults(): void {
const { projectName, prefixVarname, baseConfigVarname, installPathBinary } =
this.configSource;
let baseConfigDir = process.env[baseConfigVarname];
if (!baseConfigDir) {
/* Try to locate the configuration based on the location
* of the taler-config binary. */
const path = which(installPathBinary);
if (path) {
baseConfigDir = nodejs_fs.realpathSync(
nodejs_path.dirname(path) + `/../share/${projectName}/config.d`,
);
}
}
if (!baseConfigDir) {
baseConfigDir = `/usr/share/${projectName}/config.d`;
}
let installPrefix = process.env[prefixVarname];
if (!installPrefix) {
/* Try to locate install path based on the location
* of the taler-config binary. */
const path = which(installPathBinary);
if (path) {
installPrefix = nodejs_fs.realpathSync(
nodejs_path.dirname(path) + "/..",
);
}
}
if (!installPrefix) {
installPrefix = "/usr";
}
this.setStringSystemDefault(
"PATHS",
"LIBEXECDIR",
`${installPrefix}/${projectName}/libexec/`,
);
this.setStringSystemDefault(
"PATHS",
"DOCDIR",
`${installPrefix}/share/doc/${projectName}/`,
);
this.setStringSystemDefault(
"PATHS",
"ICONDIR",
`${installPrefix}/share/icons/`,
);
this.setStringSystemDefault(
"PATHS",
"LOCALEDIR",
`${installPrefix}/share/locale/`,
);
this.setStringSystemDefault("PATHS", "PREFIX", `${installPrefix}/`);
this.setStringSystemDefault("PATHS", "BINDIR", `${installPrefix}/bin`);
this.setStringSystemDefault(
"PATHS",
"LIBDIR",
`${installPrefix}/lib/${projectName}/`,
);
this.setStringSystemDefault(
"PATHS",
"DATADIR",
`${installPrefix}/share/${projectName}/`,
);
this.loadDefaultsFromDir(baseConfigDir);
}
private findDefaultConfigFilename(): string | undefined {
const xdg = process.env["XDG_CONFIG_HOME"];
const home = process.env["HOME"];
let fn: string | undefined;
const { projectName, componentName } = this.configSource;
if (xdg) {
fn = nodejs_path.join(xdg, `${componentName}.conf`);
} else if (home) {
fn = nodejs_path.join(home, `.config/${componentName}.conf`);
}
if (fn && nodejs_fs.existsSync(fn)) {
return fn;
}
const etc1 = `/etc/${componentName}.conf`;
if (nodejs_fs.existsSync(etc1)) {
return etc1;
}
const etc2 = `/etc/${projectName}/${componentName}.conf`;
if (nodejs_fs.existsSync(etc2)) {
return etc2;
}
return undefined;
}
static load(
filename?: string,
configSource?: ConfigSource | string,
): Configuration {
let cs: ConfigSource;
if (configSource == null) {
cs = defaultConfigSource;
} else if (typeof configSource === "string") {
if (configSource in ConfigSources) {
cs = ConfigSources[configSource as keyof typeof ConfigSources];
} else {
throw Error("invalid config source");
}
} else {
cs = configSource;
}
const cfg = new Configuration(cs);
cfg.loadDefaults();
if (filename) {
cfg.loadFromFilename(filename, false);
cfg.hintEntrypoint = filename;
} else {
const fn = cfg.findDefaultConfigFilename();
if (fn) {
// It's the default filename for the main config file,
// but we don't consider the values default values.
cfg.loadFromFilename(fn, false);
cfg.hintEntrypoint = fn;
}
}
return cfg;
}
stringify(opts: StringifyOptions = {}): string {
if (opts.excludeDefaults && this.entrypointIsComplex) {
throw Error(
"unable to do diff serialization of config file, as entry point contains complex directives",
);
}
let s = "";
if (opts.diagnostics) {
s += "# Configuration file diagnostics\n";
s += "#\n";
s += `# Entry point: ${this.hintEntrypoint ?? ""}\n`;
s += "#\n";
s += "# Loaded files:\n";
for (const f of this.loadedFiles) {
s += `# ${f.filename}\n`;
}
s += "#\n\n";
}
for (const sectionName of Object.keys(this.sectionMap)) {
const sec = this.sectionMap[sectionName];
let headerWritten = false;
for (const optionName of Object.keys(sec.entries)) {
const entry = this.sectionMap[sectionName].entries[optionName];
if (
opts.excludeDefaults &&
(entry.origin === EntryOrigin.DefaultSystem ||
entry.origin === EntryOrigin.DefaultFile)
) {
continue;
}
if (!headerWritten) {
if (opts.diagnostics && sec.secretFilename) {
s += `# Secret section from ${sec.secretFilename}\n`;
s += `# Secret accessible: ${!sec.inaccessible}\n`;
}
s += `[${sectionName}]\n`;
headerWritten = true;
}
if (entry !== undefined) {
if (opts.diagnostics) {
switch (entry.origin) {
case EntryOrigin.DefaultFile:
case EntryOrigin.Changed:
case EntryOrigin.Loaded:
s += `# ${entry.sourceFile}:${entry.sourceLine}\n`;
break;
case EntryOrigin.DefaultSystem:
s += `# (system/installation default)\n`;
break;
}
}
s += `${optionName} = ${entry.value}\n`;
}
}
if (headerWritten) {
s += "\n";
}
}
return s;
}
write(opts: { excludeDefaults?: boolean } = {}): void {
const filename = this.hintEntrypoint;
if (!filename) {
throw Error(
"unknown configuration entrypoing, unable to write back config file",
);
}
nodejs_fs.writeFileSync(
filename,
this.stringify({ excludeDefaults: opts.excludeDefaults }),
);
}
writeTo(filename: string, opts: { excludeDefaults?: boolean } = {}): void {
nodejs_fs.writeFileSync(
filename,
this.stringify({ excludeDefaults: opts.excludeDefaults }),
);
}
}