diff options
Diffstat (limited to 'packages/taler-util/src/talerconfig.ts')
-rw-r--r-- | packages/taler-util/src/talerconfig.ts | 350 |
1 files changed, 296 insertions, 54 deletions
diff --git a/packages/taler-util/src/talerconfig.ts b/packages/taler-util/src/talerconfig.ts index 59c789cae..2bd7b355f 100644 --- a/packages/taler-util/src/talerconfig.ts +++ b/packages/taler-util/src/talerconfig.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2020 Taler Systems S.A. + (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 @@ -23,12 +23,14 @@ /** * Imports */ -import { AmountJson } from "./amounts.js"; -import { Amounts } from "./amounts.js"; +import { AmountJson, Amounts } from "./amounts.js"; +import { Logger } from "./logging.js"; -import nodejs_path from "path"; -import nodejs_os from "os"; 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) { @@ -39,10 +41,30 @@ export class ConfigError extends Error { } } +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 { @@ -53,11 +75,59 @@ interface Section { 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<T> { constructor( private sectionName: string, private optionName: string, - public value: string | undefined, + private value: string | undefined, private converter: (x: string) => T, ) {} @@ -89,6 +159,10 @@ export class ConfigValue<T> { isDefined(): boolean { return this.value !== undefined; } + + getValue(): string | undefined { + return this.value; + } } /** @@ -116,9 +190,9 @@ export function expandPath(path: string): string { export function pathsub( x: string, lookup: (s: string, depth: number) => string | undefined, - depth = 0, + recursionDepth = 0, ): string { - if (depth >= 10) { + if (recursionDepth >= 128) { throw Error("recursion in path substitution"); } let s = x; @@ -157,14 +231,14 @@ export function pathsub( defaultValue = undefined; } - const r = lookup(inner, depth + 1); + const r = lookup(varname, depth + 1); if (r !== undefined) { - s = s.substr(0, start) + r + s.substr(p + 1); + 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.substr(0, start) + resolvedDefault + s.substr(p + 1); + s = s.substring(0, start) + resolvedDefault + s.substring(p + 1); l = start + resolvedDefault.length; continue; } @@ -174,9 +248,9 @@ export function pathsub( } else { const m = /^[a-zA-Z-_][a-zA-Z0-9-_]*/.exec(s.substring(l + 1)); if (m && m[0]) { - const r = lookup(m[0], depth + 1); + const r = lookup(m[0], recursionDepth + 1); if (r !== undefined) { - s = s.substr(0, l) + r + s.substr(l + 1 + m[0].length); + s = s.substring(0, l) + r + s.substring(l + 1 + m[0].length); l = l + r.length; continue; } @@ -188,13 +262,14 @@ export function pathsub( return s; } -export interface LoadOptions { +interface LoadOptions { filename?: string; banDirectives?: boolean; } export interface StringifyOptions { diagnostics?: boolean; + excludeDefaults?: boolean; } export interface LoadedFile { @@ -282,7 +357,19 @@ export class Configuration { private nestLevel = 0; - private loadFromFilename(filename: string, opts: LoadOptions = {}): void { + /** + * 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 = () => { @@ -309,7 +396,7 @@ export class Configuration { const oldNestLevel = this.nestLevel; this.nestLevel += 1; try { - this.loadFromString(s, { + this.internalLoadFromString(s, isDefaultSource, { ...opts, filename: filename, }); @@ -318,7 +405,11 @@ export class Configuration { } } - private loadGlob(parentFilename: string, fileglob: string): void { + private loadGlob( + parentFilename: string, + isDefaultSource: boolean, + fileglob: string, + ): void { const resolvedParent = nodejs_fs.realpathSync(parentFilename); const parentDir = nodejs_path.dirname(resolvedParent); @@ -339,12 +430,16 @@ export class Configuration { for (const f of files) { if (globMatch(tail, f)) { const fullPath = nodejs_path.join(head, f); - this.loadFromFilename(fullPath); + this.loadFromFilename(fullPath, isDefaultSource); } } } - private loadSecret(sectionName: string, filename: string): void { + private loadSecret( + sectionName: string, + filename: string, + isDefaultSource: boolean, + ): void { const sec = this.provideSection(sectionName); sec.secretFilename = filename; const otherCfg = new Configuration(); @@ -354,7 +449,7 @@ export class Configuration { sec.inaccessible = true; return; } - otherCfg.loadFromFilename(filename, { + otherCfg.loadFromFilename(filename, isDefaultSource, { banDirectives: true, }); const otherSec = otherCfg.provideSection(sectionName); @@ -363,7 +458,11 @@ export class Configuration { } } - loadFromString(s: string, opts: LoadOptions = {}): void { + private internalLoadFromString( + s: string, + isDefaultSource: boolean, + opts: LoadOptions = {}, + ): void { let lineNo = 0; const fn = opts.filename ?? "<input>"; const reComment = /^\s*#.*$/; @@ -390,6 +489,9 @@ export class Configuration { `invalid configuration, directive in ${fn}:${lineNo} forbidden`, ); } + if (!isDefaultSource) { + this.entrypointIsComplex = true; + } const directive = directiveMatch[1].toLowerCase(); switch (directive) { case "inline": { @@ -399,7 +501,10 @@ export class Configuration { ); } const arg = directiveMatch[2].trim(); - this.loadFromFilename(normalizeInlineFilename(opts.filename, arg)); + this.loadFromFilename( + normalizeInlineFilename(opts.filename, arg), + isDefaultSource, + ); break; } case "inline-secret": { @@ -419,7 +524,7 @@ export class Configuration { opts.filename, sp[1], ); - this.loadSecret(sp[0], secretFilename); + this.loadSecret(sp[0], secretFilename, isDefaultSource); break; } case "inline-matching": { @@ -429,7 +534,7 @@ export class Configuration { `invalid configuration, @inline-matching@ directive in ${fn}:${lineNo} can only be used from a file`, ); } - this.loadGlob(opts.filename, arg); + this.loadGlob(opts.filename, isDefaultSource, arg); break; } default: @@ -462,6 +567,9 @@ export class Configuration { value: val, sourceFile: opts.filename ?? "<unknown>", sourceLine: lineNo, + origin: isDefaultSource + ? EntryOrigin.DefaultFile + : EntryOrigin.Loaded, }; continue; } @@ -496,6 +604,24 @@ export class Configuration { value, sourceLine: 0, sourceFile: "<unknown>", + 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: "<unknown>", + origin: EntryOrigin.DefaultSystem, }; } @@ -563,11 +689,14 @@ export class Configuration { 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; } @@ -578,71 +707,146 @@ export class Configuration { ); } - loadFrom(dirname: string): void { + 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); + this.loadFromFilename(fn, true); } } private loadDefaults(): void { - let bc = process.env["TALER_BASE_CONFIG"]; - if (!bc) { + 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("taler-config"); + 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) { - bc = nodejs_fs.realpathSync( - nodejs_path.dirname(path) + "/../share/taler/config.d", + installPrefix = nodejs_fs.realpathSync( + nodejs_path.dirname(path) + "/..", ); } } - if (!bc) { - bc = "/usr/share/taler/config.d"; + if (!installPrefix) { + installPrefix = "/usr"; } - this.loadFrom(bc); + + 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); } - getDefaultConfigFilename(): string | undefined { + 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, "taler.conf"); + fn = nodejs_path.join(xdg, `${componentName}.conf`); } else if (home) { - fn = nodejs_path.join(home, ".config/taler.conf"); + fn = nodejs_path.join(home, `.config/${componentName}.conf`); } if (fn && nodejs_fs.existsSync(fn)) { return fn; } - const etc1 = "/etc/taler.conf"; + const etc1 = `/etc/${componentName}.conf`; if (nodejs_fs.existsSync(etc1)) { return etc1; } - const etc2 = "/etc/taler/taler.conf"; + const etc2 = `/etc/${projectName}/${componentName}.conf`; if (nodejs_fs.existsSync(etc2)) { return etc2; } return undefined; } - static load(filename?: string): Configuration { - const cfg = new Configuration(); + 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); + cfg.loadFromFilename(filename, false); + cfg.hintEntrypoint = filename; } else { - const fn = cfg.getDefaultConfigFilename(); + const fn = cfg.findDefaultConfigFilename(); if (fn) { - cfg.loadFromFilename(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; } } - cfg.hintEntrypoint = filename; 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"; @@ -657,26 +861,64 @@ export class Configuration { } for (const sectionName of Object.keys(this.sectionMap)) { const sec = this.sectionMap[sectionName]; - if (opts.diagnostics && sec.secretFilename) { - s += `# Secret section from ${sec.secretFilename}\n`; - s += `# Secret accessible: ${!sec.inaccessible}\n`; - } - s += `[${sectionName}]\n`; + 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) { - s += `# ${entry.sourceFile}:${entry.sourceLine}\n`; + 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`; } } - s += "\n"; + if (headerWritten) { + s += "\n"; + } } return s; } - write(filename: string): void { - nodejs_fs.writeFileSync(filename, this.stringify()); + 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 }), + ); } } |