From a8a4f76ed8be3e6173e7bc9c586f66ed70a6acf4 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 2 Aug 2021 14:11:39 +0200 Subject: implement new GNUnet config features --- packages/taler-util/package.json | 1 + packages/taler-util/src/talerconfig.ts | 425 ++++++++++++++++++++++++++++++--- packages/taler-wallet-cli/src/index.ts | 15 +- 3 files changed, 408 insertions(+), 33 deletions(-) diff --git a/packages/taler-util/package.json b/packages/taler-util/package.json index 1f306041b..730e99e81 100644 --- a/packages/taler-util/package.json +++ b/packages/taler-util/package.json @@ -23,6 +23,7 @@ "private": false, "scripts": { "prepare": "tsc", + "compile": "tsc", "test": "tsc && ava", "clean": "rimraf dist lib tsconfig.tsbuildinfo", "pretty": "prettier --write src" diff --git a/packages/taler-util/src/talerconfig.ts b/packages/taler-util/src/talerconfig.ts index 7704e2614..a40d6a126 100644 --- a/packages/taler-util/src/talerconfig.ts +++ b/packages/taler-util/src/talerconfig.ts @@ -41,6 +41,36 @@ const nodejs_fs = (function () { }; })(); +const nodejs_path = (function () { + let path: typeof import("path"); + return function () { + if (!path) { + /** + * need to use an expression when doing a require if we want + * webpack not to find out about the requirement + */ + const _r = "require"; + path = module[_r]("path"); + } + return path; + }; +})(); + +const nodejs_os = (function () { + let os: typeof import("os"); + return function () { + if (!os) { + /** + * need to use an expression when doing a require if we want + * webpack not to find out about the requirement + */ + const _r = "require"; + os = module[_r]("os"); + } + return os; + }; +})(); + export class ConfigError extends Error { constructor(message: string) { super(); @@ -50,8 +80,19 @@ export class ConfigError extends Error { } } -type OptionMap = { [optionName: string]: string }; -type SectionMap = { [sectionName: string]: OptionMap }; +interface Entry { + value: string; + sourceLine: number; + sourceFile: string; +} + +interface Section { + secretFilename?: string; + inaccessible: boolean; + entries: { [optionName: string]: Entry }; +} + +type SectionMap = { [sectionName: string]: Section }; export class ConfigValue { constructor( @@ -91,6 +132,20 @@ export class ConfigValue { } } +/** + * 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. * @@ -174,32 +229,246 @@ export function pathsub( return s; } +export interface LoadOptions { + filename?: string; + banDirectives?: boolean; +} + +export interface StringifyOptions { + diagnostics?: 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); +} + export class Configuration { private sectionMap: SectionMap = {}; - loadFromString(s: string): void { + private hintEntrypoint: string | undefined; + + private loadedFiles: LoadedFile[] = []; + + private nestLevel = 0; + + loadFromFilename(filename: string, 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.loadFromString(s, { + ...opts, + filename: filename, + }); + } finally { + this.nestLevel = oldNestLevel; + } + } + + loadGlob(parentFilename: string, 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); + } + } + } + + private loadSecret(sectionName: string, filename: string): 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, { + banDirectives: true, + }); + const otherSec = otherCfg.provideSection(sectionName); + for (const opt of Object.keys(otherSec.entries)) { + this.setString(sectionName, opt, otherSec.entries[opt].value); + } + } + + loadFromString(s: string, 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`, + ); + } + 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)); + 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); + 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, 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"); + throw Error( + `invalid configuration, expected section header in ${fn}:${lineNo}`, + ); } currentSection = currentSection.toUpperCase(); const paramMatch = line.match(reParam); @@ -209,22 +478,46 @@ export class Configuration { if (val.startsWith('"') && val.endsWith('"')) { val = val.slice(1, val.length - 1); } - const sec = this.sectionMap[currentSection] ?? {}; - this.sectionMap[currentSection] = Object.assign(sec, { - [optName]: val, - }); + const sec = this.provideSection(currentSection); + sec.entries[optName] = { + value: val, + sourceFile: opts.filename ?? "", + sourceLine: lineNo, + }; continue; } throw Error( - "invalid configuration, expected section header or option assignment", + `invalid configuration, expected section header, option assignment or directive in ${fn}:${lineNo}`, ); } } - setString(section: string, option: string, value: string): void { + 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 sec = this.sectionMap[secNorm] ?? (this.sectionMap[secNorm] = {}); - sec[option.toUpperCase()] = value; + 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: "", + }; } /** @@ -237,14 +530,14 @@ export class Configuration { getString(section: string, option: string): ConfigValue { const secNorm = section.toUpperCase(); const optNorm = option.toUpperCase(); - const val = (this.sectionMap[secNorm] ?? {})[optNorm]; + 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.sectionMap[secNorm] ?? {})[optNorm]; + const val = this.findEntry(secNorm, optNorm)?.value; return new ConfigValue(secNorm, optNorm, val, (x) => pathsub(x, (v, d) => this.lookupVariable(v, d + 1)), ); @@ -253,7 +546,7 @@ export class Configuration { getYesNo(section: string, option: string): ConfigValue { const secNorm = section.toUpperCase(); const optNorm = option.toUpperCase(); - const val = (this.sectionMap[secNorm] ?? {})[optNorm]; + const val = this.findEntry(secNorm, optNorm)?.value; const convert = (x: string): boolean => { x = x.toLowerCase(); if (x === "yes") { @@ -271,7 +564,7 @@ export class Configuration { getNumber(section: string, option: string): ConfigValue { const secNorm = section.toUpperCase(); const optNorm = option.toUpperCase(); - const val = (this.sectionMap[secNorm] ?? {})[optNorm]; + const val = this.findEntry(secNorm, optNorm)?.value; const convert = (x: string): number => { try { return Number.parseInt(x, 10); @@ -287,7 +580,7 @@ export class Configuration { 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.sectionMap["PATHS"] ?? {})[x.toUpperCase()]; + const val = this.findEntry("PATHS", x)?.value; if (val !== undefined) { return pathsub(val, (v, d) => this.lookupVariable(v, d), depth); } @@ -300,33 +593,105 @@ export class Configuration { } getAmount(section: string, option: string): ConfigValue { - const val = (this.sectionMap[section] ?? {})[option]; - return new ConfigValue(section, option, val, (x) => + const val = ( + this.sectionMap[section] ?? { + entries: {}, + } + ).entries[option]; + return new ConfigValue(section, option, val.value, (x) => Amounts.parseOrThrow(x), ); } - static load(filename: string): Configuration { - const s = nodejs_fs().readFileSync(filename, "utf-8"); + loadFrom(dirname: string): void { + const files = nodejs_fs().readdirSync(dirname); + for (const f of files) { + const fn = nodejs_path().join(dirname, f); + this.loadFromFilename(fn); + } + } + + private loadDefaults(): void { + let bc = process.env["TALER_BASE_CONFIG"]; + if (!bc) { + bc = "/usr/share/taler/config.d"; + } + this.loadFrom(bc); + } + + getDefaultConfigFilename(): string | undefined { + const xdg = process.env["XDG_CONFIG_HOME"]; + const home = process.env["HOME"]; + let fn: string | undefined; + if (xdg) { + fn = nodejs_path().join(xdg, "taler.conf"); + } else if (home) { + fn = nodejs_path().join(home, ".config/taler.conf"); + } + if (fn && nodejs_fs().existsSync(fn)) { + return fn; + } + const etc1 = "/etc/taler.conf"; + if (nodejs_fs().existsSync(etc1)) { + return etc1; + } + const etc2 = "/etc/taler/taler.conf"; + if (nodejs_fs().existsSync(etc2)) { + return etc2; + } + return undefined; + } + + static load(filename?: string): Configuration { const cfg = new Configuration(); - cfg.loadFromString(s); + cfg.loadDefaults(); + if (filename) { + cfg.loadFromFilename(filename); + } else { + const fn = cfg.getDefaultConfigFilename(); + if (fn) { + cfg.loadFromFilename(fn); + } + } + cfg.hintEntrypoint = filename; return cfg; } - write(filename: string): void { + stringify(opts: StringifyOptions = {}): string { 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]; + if (opts.diagnostics && sec.secretFilename) { + s += `# Secret section from ${sec.secretFilename}\n`; + s += `# Secret accessible: ${!sec.inaccessible}\n`; + } s += `[${sectionName}]\n`; - for (const optionName of Object.keys( - this.sectionMap[sectionName] ?? {}, - )) { - const val = this.sectionMap[sectionName][optionName]; - if (val !== undefined) { - s += `${optionName} = ${val}\n`; + for (const optionName of Object.keys(sec.entries)) { + const entry = this.sectionMap[sectionName].entries[optionName]; + if (entry !== undefined) { + if (opts.diagnostics) { + s += `# ${entry.sourceFile}:${entry.sourceLine}\n`; + } + s += `${optionName} = ${entry.value}\n`; } } s += "\n"; } - nodejs_fs().writeFileSync(filename, s); + return s; + } + + write(filename: string): void { + nodejs_fs().writeFileSync(filename, this.stringify()); } } diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index 64973c396..b7bcbd875 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -873,9 +873,18 @@ const deploymentConfigCli = deploymentCli.subcommand("configArgs", "config", { help: "Subcommands the Taler configuration.", }); -deploymentConfigCli.subcommand("show", "show").action(async (args) => { - const cfg = new Configuration(); -}); +deploymentConfigCli + .subcommand("show", "show") + .flag("diagnostics", ["-d", "--diagnostics"]) + .maybeArgument("cfgfile", clk.STRING, {}) + .action(async (args) => { + const cfg = Configuration.load(args.show.cfgfile); + console.log( + cfg.stringify({ + diagnostics: args.show.diagnostics, + }), + ); + }); const testCli = walletCli.subcommand("testingArgs", "testing", { help: "Subcommands for testing.", -- cgit v1.2.3