taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 5bbe5b792e28954a76dae0aa38f16bf9da948e03
parent 03c36d57c23f6d21bbfbe46910577a2960035761
Author: Florian Dold <dold@taler.net>
Date:   Tue, 21 Jul 2026 20:03:10 +0200

util: escape, quote and preserve names when writing a config back

A newline in a value emitted further configuration lines, leading and
trailing whitespace was lost for want of quoting, and section and option
names were upper-cased over the author's own file.

Diffstat:
Mpackages/taler-util/src/talerconfig.test.ts | 4++--
Mpackages/taler-util/src/talerconfig.ts | 38+++++++++++++++++++++++++++++++++++---
2 files changed, 37 insertions(+), 5 deletions(-)

diff --git a/packages/taler-util/src/talerconfig.test.ts b/packages/taler-util/src/talerconfig.test.ts @@ -171,7 +171,7 @@ test("a commented-out option stays a comment with CRLF line endings", (t) => { ); assert.strictEqual( config.stringify(), - "[EXCHANGE]\nBASE_URL = https://real/\n\n", + "[exchange]\nBASE_URL = https://real/\n\n", ); }); @@ -204,7 +204,7 @@ test("% introduces a comment, as in the C implementation", (t) => { ); assert.strictEqual( config.stringify(), - "[EXCHANGE]\nBASE_URL = https://real/\n\n", + "[exchange]\nBASE_URL = https://real/\n\n", ); }); diff --git a/packages/taler-util/src/talerconfig.ts b/packages/taler-util/src/talerconfig.ts @@ -61,6 +61,11 @@ enum EntryOrigin { } interface Entry { + /** + * Option name as it was first written. Lookups are case insensitive, but + * write-back overwrites the user's own file and must not respell it. + */ + name: string; value: string; sourceLine: number; sourceFile: string; @@ -68,6 +73,10 @@ interface Entry { } interface Section { + /** + * Section name as it was first written; see Entry.name. + */ + name: string; secretFilename?: string; inaccessible: boolean; entries: { [optionName: string]: Entry }; @@ -398,6 +407,22 @@ function compareFilenames(a: string, b: string): number { return ba.length - bb.length; } +/** + * Render a value so that reading it back yields the same string. + * + * A newline would otherwise emit further configuration lines, which is how a + * value turns into an option the author never wrote; the C rewrites it to a + * literal backslash-n. Leading and trailing whitespace only survives inside + * quotes, which the reader strips again. + */ +function serializeValue(value: string): string { + const escaped = value.replace(/\n/g, "\\n").replace(/\r/g, "\\r"); + if (escaped !== escaped.trim() || /^".*"$/.test(escaped)) { + return `"${escaped}"`; + } + return escaped; +} + export class Configuration { private sectionMap: SectionMap = {}; @@ -619,7 +644,8 @@ export class Configuration { `invalid configuration, expected section header in ${fn}:${lineNo}`, ); } - currentSection = currentSection.toUpperCase(); + // Kept as written: provideSection() upper-cases for the lookup key, and + // the original spelling is what write-back emits. const paramMatch = line.match(reParam); if (paramMatch) { const optName = paramMatch[1].toUpperCase(); @@ -629,6 +655,7 @@ export class Configuration { } const sec = this.provideSection(currentSection); sec.entries[optName] = { + name: paramMatch[1], value: val, sourceFile: opts.filename ?? "<unknown>", sourceLine: lineNo, @@ -650,6 +677,7 @@ export class Configuration { return this.sectionMap[secNorm]; } const newSec: Section = { + name: section, entries: {}, inaccessible: false, }; @@ -679,6 +707,7 @@ export class Configuration { setString(section: string, option: string, value: string): void { const sec = this.provideSection(section); sec.entries[option.toUpperCase()] = { + name: option, value, sourceLine: 0, sourceFile: "<unknown>", @@ -696,6 +725,7 @@ export class Configuration { ): void { const sec = this.provideSection(section); sec.entries[option.toUpperCase()] = { + name: option, value, sourceLine: 0, sourceFile: "<unknown>", @@ -949,6 +979,8 @@ export class Configuration { } for (const sectionName of Object.keys(this.sectionMap)) { const sec = this.sectionMap[sectionName]; + // Names are written the way the author spelled them, not upper-cased; + // write() overwrites the entry point config file in place. let headerWritten = false; for (const optionName of Object.keys(sec.entries)) { const entry = this.sectionMap[sectionName].entries[optionName]; @@ -964,7 +996,7 @@ export class Configuration { s += `# Secret section from ${sec.secretFilename}\n`; s += `# Secret accessible: ${!sec.inaccessible}\n`; } - s += `[${sectionName}]\n`; + s += `[${sec.name}]\n`; headerWritten = true; } if (entry !== undefined) { @@ -980,7 +1012,7 @@ export class Configuration { break; } } - s += `${optionName} = ${entry.value}\n`; + s += `${entry.name} = ${serializeValue(entry.value)}\n`; } } if (headerWritten) {