From 80892d016eecced30e6529f907f82d9671e1fab1 Mon Sep 17 00:00:00 2001 From: Paul Galbraith Date: Thu, 13 Nov 2025 10:57:30 -0500 Subject: [PATCH 1/3] feat: support for multiline values [issue #33] Issue #33 was closed as completed, but I think it was really rejected. This adds support for multine entries as requested in the issue, similar to the way Python configparser handles ini files. Multiline values are a convenience when you have a large value (e.g. a comma separated list) assigned to a single key. Breaking the value across several lines makes for much easier reading and maintenance. --- CHANGELOG.md | 4 + README.md | 36 ++- lib/ini.js | 89 ++++++- tap-snapshots/test/foo.js.test.cjs | 361 +++++++++++++++++++++++++- test/fixtures/foo-multiline-error.ini | 6 + test/fixtures/foo.ini | 58 ++++- test/foo.js | 22 ++ 7 files changed, 563 insertions(+), 13 deletions(-) create mode 100644 test/fixtures/foo-multiline-error.ini diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ed8c0..4e2ef3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [Unreleased] + +* add multiline parsing/encoding support with `multiline` (parse) and `strictMultiline` (stringify) options + ## [6.0.0](https://github.com/npm/ini/compare/v5.0.0...v6.0.0) (2025-10-22) ### ⚠️ BREAKING CHANGES * `ini` now supports node `^20.17.0 || >=22.9.0` diff --git a/README.md b/README.md index c6eee00..95e9f6d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ scope=local user=dbuser password=dbpassword database=use_another_database +description=this is a multiline + value -- continuation lines for multiline + values must be indented [section.paths.default] tmpdir=/tmp array[]=first value @@ -87,7 +90,22 @@ Attempts to turn the given INI string into a nested data object. ```js // You can also use `decode` -const object = parse(``) +const object = parse(``, { + /** + * Interpret indented lines that immediately follow key/value pairs + * as multiline continuations. Enabled by default for backwards + * compatibility with the Node.js parser this module replaces. + * + * Set to `false` to treat those indented lines as standalone keys. + */ + multiline: true, + + /** + * Whether to treat repeated keys without a `[]` suffix as arrays. + * Enabled by default to match the .ini format used by npm. + */ + bracketedArray: true, +}) ``` ### Stringify @@ -152,7 +170,21 @@ stringify(object,{ * Some parsers treat duplicate names by themselves as arrays */ - bracketedArray : true + bracketedArray : true, + + /** + * Enforce indentation on continuation lines for string values + * that contain literal newlines. + * + * When `true` (default), `stringify()` throws if any line after + * the first does not start with a space or tab. This protects + * against accidentally emitting multiline INI values that other + * parsers cannot safely read. + * + * Set to `false` to fall back to JSON-quoted output for those + * values (the legacy behavior prior to multiline support). + */ + strictMultiline : true }) ``` diff --git a/lib/ini.js b/lib/ini.js index beb390d..9ee881b 100644 --- a/lib/ini.js +++ b/lib/ini.js @@ -8,6 +8,7 @@ const encode = (obj, opt = {}) => { opt.newline = opt.newline === true opt.sort = opt.sort === true opt.whitespace = opt.whitespace === true || opt.align === true + opt.strictMultiline = opt.strictMultiline !== false // The `typeof` check is required because accessing the `process` directly fails on browsers. /* istanbul ignore next */ opt.platform = opt.platform || (typeof process !== 'undefined' && process.platform) @@ -47,12 +48,58 @@ const encode = (obj, opt = {}) => { const val = obj[k] if (val && Array.isArray(val)) { for (const item of val) { - out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + safe(item) + eol + if (typeof item === 'string' && item.includes('\n')) { + const parts = item.split('\n') + let allIndented = true + for (let i = 1; i < parts.length; i++) { + const ln = parts[i] + if (ln.length > 0 && !/^[ \t]/.test(ln)) { + allIndented = false + break + } + } + if (!allIndented) { + if (opt.strictMultiline) { + throw new Error('Array entry value has an embedded newline but no indentation whitespace after the newline. Disable strictMultiline to allow such a value.') + } + out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + safe(item) + eol + } else { + out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + parts[0] + eol + for (let i = 1; i < parts.length; i++) { + out += parts[i] + eol + } + } + } else { + out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + safe(item) + eol + } } } else if (val && typeof val === 'object') { children.push(k) } else { - out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol + if (typeof val === 'string' && val.includes('\n')) { + const parts = val.split('\n') + let allIndented = true + for (let i = 1; i < parts.length; i++) { + const ln = parts[i] + if (ln.length > 0 && !/^[ \t]/.test(ln)) { + allIndented = false + break + } + } + if (!allIndented) { + if (opt.strictMultiline) { + throw new Error('Entry value has an embedded newline but no indentation whitespace after the newline. Disable strictMultiline to allow such a value.') + } + out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol + } else { + out += safe(k).padEnd(padToChars, ' ') + separator + parts[0] + eol + for (let i = 1; i < parts.length; i++) { + out += parts[i] + eol + } + } + } else { + out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol + } } } @@ -105,16 +152,35 @@ function splitSections (str, separator) { const decode = (str, opt = {}) => { opt.bracketedArray = opt.bracketedArray !== false + opt.multiline = opt.multiline !== false const out = Object.create(null) let p = out let section = null // section |key = value const re = /^\[([^\]]*)\]\s*$|^([^=]+)(=(.*))?$/i - const lines = str.split(/[\r\n]+/g) + const lines = str.split(/\r?\n/g) const duplicates = {} + let lastKey = null + let lastKeyContainer = null + let canContinue = false for (const line of lines) { - if (!line || line.match(/^\s*[;#]/) || line.match(/^\s*$/)) { + if (!line) { + canContinue = false + continue + } + if (line.match(/^\s*[;#]/)) { + // comments do not break continuation, just skip + continue + } + if (line.match(/^\s*$/)) { + // blank lines break continuation + canContinue = false + continue + } + + if (opt.multiline && canContinue && lastKey && lastKeyContainer && /^[ \t]/.test(line) && line.indexOf('=') === -1) { + lastKeyContainer[lastKey] += '\n' + line continue } const match = line.match(re) @@ -127,9 +193,11 @@ const decode = (str, opt = {}) => { // not allowed // keep parsing the section, but don't attach it. p = Object.create(null) + canContinue = false continue } p = out[section] = out[section] || Object.create(null) + canContinue = false continue } const keyRaw = unsafe(match[2]) @@ -144,6 +212,7 @@ const decode = (str, opt = {}) => { ? keyRaw.slice(0, -2) : keyRaw if (key === '__proto__') { + canContinue = false continue } const valueRaw = match[3] ? unsafe(match[4]) : true @@ -165,8 +234,20 @@ const decode = (str, opt = {}) => { // array by accidentally forgetting the brackets if (Array.isArray(p[key])) { p[key].push(value) + canContinue = false + lastKey = null + lastKeyContainer = null } else { p[key] = value + if (opt.multiline && typeof value === 'string') { + canContinue = true + lastKey = key + lastKeyContainer = p + } else { + canContinue = false + lastKey = null + lastKeyContainer = null + } } } diff --git a/tap-snapshots/test/foo.js.test.cjs b/tap-snapshots/test/foo.js.test.cjs index 2646323..bcdc470 100644 --- a/tap-snapshots/test/foo.js.test.cjs +++ b/tap-snapshots/test/foo.js.test.cjs @@ -8,8 +8,8 @@ exports[`test/foo.js TAP decode from file > must match snapshot 1`] = ` Null Object { " xa n p ": String( - "\\r - yoyoyo\\r\\r + "r + oyoyor\\r ), "[disturbing]": "hey you never know", @@ -39,6 +39,172 @@ Null Object { "br": "warm", "eq": "eq=eq", "false": false, + "multiline": Null Object { + "alpha": Null Object { + "a1": "one", + "a2": "two", + "a3": "three", + "b1": "two", + "five": String( + line1 + line2 + \\tline3 + line4 + \\t line5 + ), + "two": String( + first + second + ), + }, + "array": Null Object { + "good": true, + "line2": true, + "line3": true, + "list": Array [ + "item1", + "line1", + "ok", + String( + good + indented + ), + ], + }, + "beta": Null Object { + "b1": "after", + "mstart": String( + top + middle + \\tbottom + ), + }, + "delta": Null Object { + "d1": "whitespace on following newline", + "d2": "done", + "dml": "first", + "second": true, + "third": true, + }, + "gamma": Null Object { + "g1": "before", + "mnext": String( + x + y + \\tz + ), + }, + }, + "null": null, + "o": "p", + "s": "something", + "s1": "\\"something'", + "s2": "something else", + "s3": "", + "s4": "", + "s5": " ", + "s6": " a ", + "s7": true, + "true": true, + "undefined": "undefined", + "x.y.z": Null Object { + "a.b.c": Null Object { + "a.b.c": "abc", + "nocomment": "this; this is not a comment", + "noHashComment": "this# this is not a comment", + }, + "x.y.z": "xyz", + }, + "zr": Array [ + "deedee", + ], +} +` + +exports[`test/foo.js TAP decode from file with multiline disabled > must match snapshot 1`] = ` +Null Object { + " xa n p ": String( + "r + oyoyor\\r + + ), + "[disturbing]": "hey you never know", + "a": Null Object { + "[]": "a square?", + "av": "a val", + "b": Null Object { + "c": Null Object { + "e": "1", + "j": "2", + }, + }, + "cr": Array [ + "four", + "eight", + ], + "e": "{ o: p, a: { av: a val, b: { c: { e: \\"this [value]\\" } } } }", + "j": "\\"{ o: \\"p\\", a: { av: \\"a val\\", b: { c: { e: \\"this [value]\\" } } } }\\"", + }, + "a with spaces": "b c", + "ar": Array [ + "one", + "three", + "this is included", + ], + "b": Null Object {}, + "br": "warm", + "eq": "eq=eq", + "false": false, + "multiline": Null Object { + "alpha": Null Object { + "a1": "one", + "a2": "two", + "a3": "three", + "b1": "two", + "five": "line1", + "line2": true, + "line3": true, + "line4": true, + "line5": true, + "multiline value across": true, + "second": true, + "three lines": true, + "two": "first", + }, + "array": Null Object { + "good": true, + "line2": true, + "line3": true, + "list": Array [ + "item1", + "line1", + "ok", + String( + good + indented + ), + ], + }, + "beta": Null Object { + "b1": "after", + "bottom": true, + "middle": true, + "mstart": "top", + }, + "delta": Null Object { + "d1": "whitespace on following newline", + "d2": "done", + "dml": "first", + "second": true, + "third": true, + }, + "gamma": Null Object { + "g1": "before", + "mnext": "x", + "y": true, + "z": true, + }, + }, "null": null, "o": "p", "s": "something", @@ -68,7 +234,9 @@ Null Object { exports[`test/foo.js TAP encode from data > must match snapshot 1`] = ` o=p a with spaces=b c -" xa n p "="\\"\\r\\nyoyoyo\\r\\r\\n" +" xa n p "="r + oyoyor + "[disturbing]"=hey you never know s=something s1="something' @@ -109,12 +277,56 @@ a.b.c=abc nocomment=this\\; this is not a comment noHashComment=this\\# this is not a comment +[multiline.alpha] +a1=one +a2=two +b1=two +two=first + second +five=line1 + line2 + line3 + line4 + line5 +a3=three + +[multiline.beta] +mstart=top + middle + bottom +b1=after + +[multiline.gamma] +g1=before +mnext=x + y + z + +[multiline.delta] +dml=first +second=true +third=true +d1=whitespace on following newline +d2=done + +[multiline.array] +list[]=item1 +list[]=line1 +list[]=ok +list[]=good + indented +line2=true +line3=true +good=true + ` exports[`test/foo.js TAP encode with align > must match snapshot 1`] = ` o = p a with spaces = b c -" xa n p " = "\\"\\r\\nyoyoyo\\r\\r\\n" +" xa n p " = "r + oyoyor + "[disturbing]" = hey you never know s = something s1 = "something' @@ -155,10 +367,54 @@ a.b.c = abc nocomment = this\\; this is not a comment noHashComment = this\\# this is not a comment +[multiline.alpha] +a1 = one +a2 = two +b1 = two +two = first + second +five = line1 + line2 + line3 + line4 + line5 +a3 = three + +[multiline.beta] +mstart = top + middle + bottom +b1 = after + +[multiline.gamma] +g1 = before +mnext = x + y + z + +[multiline.delta] +dml = first +second = true +third = true +d1 = whitespace on following newline +d2 = done + +[multiline.array] +list[] = item1 +list[] = line1 +list[] = ok +list[] = good + indented +line2 = true +line3 = true +good = true + ` exports[`test/foo.js TAP encode with align and sort > must match snapshot 1`] = ` -" xa n p " = "\\"\\r\\nyoyoyo\\r\\r\\n" +" xa n p " = "r + oyoyor + "[disturbing]" = hey you never know a with spaces = b c ar[] = one @@ -193,6 +449,48 @@ j = "\\"{ o: \\"p\\", a: { av: \\"a val\\", b: { c: { e: \\"this [value]\\" } e = 1 j = 2 +[multiline.alpha] +a1 = one +a2 = two +a3 = three +b1 = two +five = line1 + line2 + line3 + line4 + line5 +two = first + second + +[multiline.array] +good = true +line2 = true +line3 = true +list[] = item1 +list[] = line1 +list[] = ok +list[] = good + indented + +[multiline.beta] +b1 = after +mstart = top + middle + bottom + +[multiline.delta] +d1 = whitespace on following newline +d2 = done +dml = first +second = true +third = true + +[multiline.gamma] +g1 = before +mnext = x + y + z + [x\\.y\\.z] x.y.z = xyz @@ -238,7 +536,9 @@ Array [ ` exports[`test/foo.js TAP encode with sort > must match snapshot 1`] = ` -" xa n p "="\\"\\r\\nyoyoyo\\r\\r\\n" +" xa n p "="r + oyoyor + "[disturbing]"=hey you never know a with spaces=b c ar[]=one @@ -273,6 +573,48 @@ j="\\"{ o: \\"p\\", a: { av: \\"a val\\", b: { c: { e: \\"this [value]\\" } } } e=1 j=2 +[multiline.alpha] +a1=one +a2=two +a3=three +b1=two +five=line1 + line2 + line3 + line4 + line5 +two=first + second + +[multiline.array] +good=true +line2=true +line3=true +list[]=item1 +list[]=line1 +list[]=ok +list[]=good + indented + +[multiline.beta] +b1=after +mstart=top + middle + bottom + +[multiline.delta] +d1=whitespace on following newline +d2=done +dml=first +second=true +third=true + +[multiline.gamma] +g1=before +mnext=x + y + z + [x\\.y\\.z] x.y.z=xyz @@ -302,3 +644,10 @@ label=debug value=10 ` + +exports[`test/foo.js TAP legacy encode preserves problematic multiline entry > must match snapshot 1`] = ` +Array [ + "\\" xa n p \\"=\\"\\\\\\"\\\\r\\\\nyoyoyo\\\\r\\\\r\\\\n\\"", + "", +] +` diff --git a/test/fixtures/foo-multiline-error.ini b/test/fixtures/foo-multiline-error.ini new file mode 100644 index 0000000..2469eba --- /dev/null +++ b/test/fixtures/foo-multiline-error.ini @@ -0,0 +1,6 @@ +; This is the verbatim original value entry from foo.ini, which causes +; problems for multline roundtripping due to embedded newline without any +; following indent whistespace. + +; wrap in quotes to JSON-decode and preserve spaces +" xa n p " = "\"\r\nyoyoyo\r\r\n" diff --git a/test/fixtures/foo.ini b/test/fixtures/foo.ini index 219b4aa..c1d24bd 100644 --- a/test/fixtures/foo.ini +++ b/test/fixtures/foo.ini @@ -3,7 +3,10 @@ o = p a with spaces = b c ; wrap in quotes to JSON-decode and preserve spaces -" xa n p " = "\"\r\nyoyoyo\r\r\n" +; (Original value modified for roundtrip encoding to work with multiline encoding. +; Embedded \r and \n in values make it difficult to preserve those entries exactly +; across roundtrips.) +" xa n p " = "\"r\n oyoyor\r\n" ; wrap in quotes to get a key with a bracket, not a section. "[disturbing]" = hey you never know @@ -93,3 +96,56 @@ nocomment = this\; this is not a comment # this next one is not a comment! it's escaped! noHashComment = this\# this is not a comment + +; Multiline entry testing + +[multiline.alpha] +a1=one +a2 = a typical long + multiline value across + three lines +b1=two +two = first + second + a2 = two +five=line1 + line2 +# tab here: + line3 + line4 +;tab here: + line5 +a3=three + +[multiline.beta] +mstart=top + middle + bottom +b1=after + +[multiline.gamma] +g1=before +mnext=x + y + ; tab here: + z + +[multiline.delta] + +dml=first + + second + third + +d1=whitespace on following newline + +d2=done + +[multiline.array] +list[]=item1 +list[]=line1 + line2 + line3 +list[]=ok + good +list[]="good\n indented" diff --git a/test/foo.js b/test/foo.js index fe92435..3b37b81 100644 --- a/test/foo.js +++ b/test/foo.js @@ -5,6 +5,8 @@ const fs = require('fs') const path = require('path') const fixture = path.resolve(__dirname, './fixtures/foo.ini') const data = fs.readFileSync(fixture, 'utf8') +const errorFixture = path.resolve(__dirname, './fixtures/foo-multiline-error.ini') +const errorData = fs.readFileSync(errorFixture, 'utf8') tap.cleanSnapshot = s => s.replace(/\r\n/g, '\n') @@ -14,6 +16,12 @@ test('decode from file', function (t) { t.end() }) +test('decode from file with multiline disabled', function (t) { + const d = i.decode(data, { multiline: false }) + t.matchSnapshot(d) + t.end() +}) + test('encode from data', function (t) { const d = i.decode(data) const e = i.encode(d) @@ -94,3 +102,17 @@ test('encode within browser context', function (t) { t.matchSnapshot(e) t.end() }) + +test('legacy encode preserves problematic multiline entry', function (t) { + const obj = i.decode(errorData) + const encoded = i.encode(obj, { strictMultiline: false }) + // make sure the lone \r is treated as newline on Windows + t.matchSnapshot(encoded.split(/\r?\n/)) + t.end() +}) + +test('strict encode fails on problematic multiline entry', function (t) { + const obj = i.decode(errorData) + t.throws(() => i.encode(obj, { strictMultiline: true })) + t.end() +}) From 870938e6da208f866dd05dfd51be2b76259d3b77 Mon Sep 17 00:00:00 2001 From: Paul Galbraith Date: Sat, 12 Sep 2026 01:16:42 -0400 Subject: [PATCH 2/3] fix: reject carriage returns in multiline continuation output - treat a value containing \r as ineligible for verbatim continuation lines, since the decoder drops a CR before LF and the win32 eol adds another, which made the encode snapshots platform-dependent - drop the \r from the modified foo.ini fixture value - add tests for the array-entry strict and legacy paths and for carriage returns, restoring 100% coverage - document the carriage-return rule for strictMultiline in the README --- README.md | 8 +++++--- lib/ini.js | 12 ++++++++---- tap-snapshots/test/foo.js.test.cjs | 4 ++-- test/fixtures/foo.ini | 8 ++++---- test/foo.js | 19 +++++++++++++++++++ 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 95e9f6d..1d3e4e8 100644 --- a/README.md +++ b/README.md @@ -177,9 +177,11 @@ stringify(object,{ * that contain literal newlines. * * When `true` (default), `stringify()` throws if any line after - * the first does not start with a space or tab. This protects - * against accidentally emitting multiline INI values that other - * parsers cannot safely read. + * the first does not start with a space or tab, or if the value + * contains a carriage return (only LF line breaks can be written + * as continuation lines). This protects against accidentally + * emitting multiline INI values that other parsers cannot safely + * read. * * Set to `false` to fall back to JSON-quoted output for those * values (the legacy behavior prior to multiline support). diff --git a/lib/ini.js b/lib/ini.js index 9ee881b..8f93f55 100644 --- a/lib/ini.js +++ b/lib/ini.js @@ -58,9 +58,11 @@ const encode = (obj, opt = {}) => { break } } - if (!allIndented) { + // A carriage return cannot be written verbatim: the decoder drops a + // CR that precedes LF, and on win32 the platform eol adds another. + if (!allIndented || item.includes('\r')) { if (opt.strictMultiline) { - throw new Error('Array entry value has an embedded newline but no indentation whitespace after the newline. Disable strictMultiline to allow such a value.') + throw new Error('Array entry value has an embedded newline but cannot be written as indented continuation lines (a line is not indented, or the value contains a carriage return). Disable strictMultiline to allow such a value.') } out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + safe(item) + eol } else { @@ -86,9 +88,11 @@ const encode = (obj, opt = {}) => { break } } - if (!allIndented) { + // A carriage return cannot be written verbatim: the decoder drops a + // CR that precedes LF, and on win32 the platform eol adds another. + if (!allIndented || val.includes('\r')) { if (opt.strictMultiline) { - throw new Error('Entry value has an embedded newline but no indentation whitespace after the newline. Disable strictMultiline to allow such a value.') + throw new Error('Entry value has an embedded newline but cannot be written as indented continuation lines (a line is not indented, or the value contains a carriage return). Disable strictMultiline to allow such a value.') } out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol } else { diff --git a/tap-snapshots/test/foo.js.test.cjs b/tap-snapshots/test/foo.js.test.cjs index bcdc470..11a3d61 100644 --- a/tap-snapshots/test/foo.js.test.cjs +++ b/tap-snapshots/test/foo.js.test.cjs @@ -9,7 +9,7 @@ exports[`test/foo.js TAP decode from file > must match snapshot 1`] = ` Null Object { " xa n p ": String( "r - oyoyor\\r + oyoyor ), "[disturbing]": "hey you never know", @@ -125,7 +125,7 @@ exports[`test/foo.js TAP decode from file with multiline disabled > must match s Null Object { " xa n p ": String( "r - oyoyor\\r + oyoyor ), "[disturbing]": "hey you never know", diff --git a/test/fixtures/foo.ini b/test/fixtures/foo.ini index c1d24bd..47a8532 100644 --- a/test/fixtures/foo.ini +++ b/test/fixtures/foo.ini @@ -3,10 +3,10 @@ o = p a with spaces = b c ; wrap in quotes to JSON-decode and preserve spaces -; (Original value modified for roundtrip encoding to work with multiline encoding. -; Embedded \r and \n in values make it difficult to preserve those entries exactly -; across roundtrips.) -" xa n p " = "\"r\n oyoyor\r\n" +; (Original value modified so it can be encoded as indented continuation lines: +; a value with a carriage return, or with an unindented line after a newline, +; is not eligible. The original value lives on in foo-multiline-error.ini.) +" xa n p " = "\"r\n oyoyor\n" ; wrap in quotes to get a key with a bracket, not a section. "[disturbing]" = hey you never know diff --git a/test/foo.js b/test/foo.js index 3b37b81..4696dbf 100644 --- a/test/foo.js +++ b/test/foo.js @@ -116,3 +116,22 @@ test('strict encode fails on problematic multiline entry', function (t) { t.throws(() => i.encode(obj, { strictMultiline: true })) t.end() }) + +test('strict encode fails on array entry with unindented newline', function (t) { + t.throws(() => i.encode({ list: ['a\nb'] }), /Array entry value/) + t.end() +}) + +test('legacy encode quotes array entry with unindented newline', function (t) { + const e = i.encode({ list: ['a\nb'] }, { strictMultiline: false }) + t.same(e.split(/\r?\n/), ['list[]="a\\nb"', '']) + t.end() +}) + +test('carriage return is never written as a continuation line', function (t) { + const obj = { key: 'a\r\n b', list: ['c\r\n d'] } + t.throws(() => i.encode(obj), /carriage return/) + const e = i.encode(obj, { strictMultiline: false }) + t.same(e.split(/\r?\n/), ['key="a\\r\\n b"', 'list[]="c\\r\\n d"', '']) + t.end() +}) From 6d0ba77965b2382faf40739eae495b4c641c4290 Mon Sep 17 00:00:00 2001 From: Paul Galbraith Date: Sat, 12 Sep 2026 11:06:12 -0400 Subject: [PATCH 3/3] fix: make multiline continuation output round-trip through parse - write a value as indented continuation lines only when every line after the first is indented, non-blank, free of "=", not a comment, and the value has no carriage return; otherwise JSON-quote it - escape the first line with safe() like any other value - continue array entries in the decoder, matching what the encoder emits - clear the continuation state on any line that is not a comment or a continuation, including unparseable lines - default strictMultiline to false so stringify never throws where 7.0.0 did not; true still rejects values that cannot be written verbatim - restore the original foo.ini fixture value and add explicit round-trip tests for every case; document the rules in the README --- README.md | 55 +++++++---- lib/ini.js | 137 +++++++++++--------------- tap-snapshots/test/foo.js.test.cjs | 62 ++++++------ test/fixtures/foo-multiline-error.ini | 6 +- test/fixtures/foo.ini | 5 +- test/foo.js | 61 ++++++++++-- 6 files changed, 180 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index 1d3e4e8..6d5e667 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ const config = parse(text) config.scope = 'local' config.database.database = 'use_another_database' +config.database.description = 'this is a multiline\n value -- continuation lines\n must be indented' config.paths.default.tmpdir = '/tmp' delete config.paths.default.datadir config.paths.default.array.push('fourth value') @@ -71,9 +72,9 @@ scope=local user=dbuser password=dbpassword database=use_another_database -description=this is a multiline - value -- continuation lines for multiline - values must be indented +description=this is a multiline + value -- continuation lines + must be indented [section.paths.default] tmpdir=/tmp array[]=first value @@ -92,11 +93,12 @@ Attempts to turn the given INI string into a nested data object. // You can also use `decode` const object = parse(``, { /** - * Interpret indented lines that immediately follow key/value pairs - * as multiline continuations. Enabled by default for backwards - * compatibility with the Node.js parser this module replaces. + * Read indented lines that directly follow a key/value pair as + * continuation lines of that value, the way Python's configparser + * does. Enabled by default; see "Multiline values" below. * - * Set to `false` to treat those indented lines as standalone keys. + * Set to `false` to read those indented lines as standalone keys, + * which is how versions before multiline support read them. */ multiline: true, @@ -173,20 +175,16 @@ stringify(object,{ bracketedArray : true, /** - * Enforce indentation on continuation lines for string values - * that contain literal newlines. + * A string value containing newlines is written as indented + * continuation lines when `parse()` can read it back unchanged + * (see "Multiline values" below), and JSON-quoted otherwise, + * as before multiline support. * - * When `true` (default), `stringify()` throws if any line after - * the first does not start with a space or tab, or if the value - * contains a carriage return (only LF line breaks can be written - * as continuation lines). This protects against accidentally - * emitting multiline INI values that other parsers cannot safely - * read. - * - * Set to `false` to fall back to JSON-quoted output for those - * values (the legacy behavior prior to multiline support). + * Set to `true` to have `stringify()` throw instead of falling + * back to JSON quoting, for output that must stay readable by + * parsers that do not understand quoted values. */ - strictMultiline : true + strictMultiline : false }) ``` @@ -198,6 +196,25 @@ stringify(object,{ stringify(object,'section') ``` +### Multiline values + +A value continues onto the following lines when each of them starts with +a space or tab. The continuation lines are kept verbatim, indentation +included, joined with `\n`: + +```ini +description=first line + second line + third line +``` + +`parse()` ends the value at a blank line, a section header, or any line +that is not indented, and skips comment lines in between. `stringify()` +writes a value this way only when every line after the first is indented, +is not blank, contains no `=`, and does not start with `;` or `#`, and the +value contains no carriage return. Any other value with newlines is +JSON-quoted, so `parse(stringify(x))` gives back `x` either way. + ### Un / Escape Turn the given string into a safe to diff --git a/lib/ini.js b/lib/ini.js index 8f93f55..5bcf2e4 100644 --- a/lib/ini.js +++ b/lib/ini.js @@ -1,5 +1,29 @@ const { hasOwnProperty } = Object.prototype +// A string value with embedded newlines can be written verbatim as indented +// continuation lines only if the decoder will read every one of those lines +// back as part of the same value. Returns the lines to write, or null when +// the value has to be JSON-quoted instead. +const continuationLines = val => { + // The decoder drops a CR that precedes LF, and on win32 the platform eol + // would add a second one. + if (val.includes('\r')) { + return null + } + const lines = val.split('\n') + for (const ln of lines.slice(1)) { + if ( + !/^[ \t]/.test(ln) || // must be indented + !/\S/.test(ln) || // a blank line ends the value + ln.includes('=') || // would parse as a new key + /^\s*[;#]/.test(ln) // would be skipped as a comment + ) { + return null + } + } + return lines +} + const encode = (obj, opt = {}) => { if (typeof opt === 'string') { opt = { section: opt } @@ -8,7 +32,7 @@ const encode = (obj, opt = {}) => { opt.newline = opt.newline === true opt.sort = opt.sort === true opt.whitespace = opt.whitespace === true || opt.align === true - opt.strictMultiline = opt.strictMultiline !== false + opt.strictMultiline = opt.strictMultiline === true // The `typeof` check is required because accessing the `process` directly fails on browsers. /* istanbul ignore next */ opt.platform = opt.platform || (typeof process !== 'undefined' && process.platform) @@ -44,66 +68,36 @@ const encode = (obj, opt = {}) => { let out = '' const arraySuffix = opt.bracketedArray ? '[]' : '' + // Write one key/value line, as indented continuation lines when the value + // allows it. + const entry = (key, val) => { + let text = null + if (typeof val === 'string' && val.includes('\n')) { + const lines = continuationLines(val) + if (lines) { + // The first line is escaped like any other value; the continuation + // lines are read back verbatim. + text = safe(lines[0]) + eol + lines.slice(1).join(eol) + } else if (opt.strictMultiline) { + throw new Error(`Value of "${key}" has an embedded newline but cannot be written as indented continuation lines. Disable strictMultiline to allow such a value.`) + } + } + if (text === null) { + text = safe(val) + } + out += safe(key).padEnd(padToChars, ' ') + separator + text + eol + } + for (const k of keys) { const val = obj[k] if (val && Array.isArray(val)) { for (const item of val) { - if (typeof item === 'string' && item.includes('\n')) { - const parts = item.split('\n') - let allIndented = true - for (let i = 1; i < parts.length; i++) { - const ln = parts[i] - if (ln.length > 0 && !/^[ \t]/.test(ln)) { - allIndented = false - break - } - } - // A carriage return cannot be written verbatim: the decoder drops a - // CR that precedes LF, and on win32 the platform eol adds another. - if (!allIndented || item.includes('\r')) { - if (opt.strictMultiline) { - throw new Error('Array entry value has an embedded newline but cannot be written as indented continuation lines (a line is not indented, or the value contains a carriage return). Disable strictMultiline to allow such a value.') - } - out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + safe(item) + eol - } else { - out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + parts[0] + eol - for (let i = 1; i < parts.length; i++) { - out += parts[i] + eol - } - } - } else { - out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + safe(item) + eol - } + entry(`${k}${arraySuffix}`, item) } } else if (val && typeof val === 'object') { children.push(k) } else { - if (typeof val === 'string' && val.includes('\n')) { - const parts = val.split('\n') - let allIndented = true - for (let i = 1; i < parts.length; i++) { - const ln = parts[i] - if (ln.length > 0 && !/^[ \t]/.test(ln)) { - allIndented = false - break - } - } - // A carriage return cannot be written verbatim: the decoder drops a - // CR that precedes LF, and on win32 the platform eol adds another. - if (!allIndented || val.includes('\r')) { - if (opt.strictMultiline) { - throw new Error('Entry value has an embedded newline but cannot be written as indented continuation lines (a line is not indented, or the value contains a carriage return). Disable strictMultiline to allow such a value.') - } - out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol - } else { - out += safe(k).padEnd(padToChars, ' ') + separator + parts[0] + eol - for (let i = 1; i < parts.length; i++) { - out += parts[i] + eol - } - } - } else { - out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol - } + entry(k, val) } } @@ -164,29 +158,27 @@ const decode = (str, opt = {}) => { const re = /^\[([^\]]*)\]\s*$|^([^=]+)(=(.*))?$/i const lines = str.split(/\r?\n/g) const duplicates = {} - let lastKey = null - let lastKeyContainer = null - let canContinue = false + // While an indented line may still continue the most recent string value, + // `cont` says where that value lives: [object or array, key or index]. + // Anything other than a comment or a continuation line clears it. + let cont = null for (const line of lines) { - if (!line) { - canContinue = false - continue - } if (line.match(/^\s*[;#]/)) { // comments do not break continuation, just skip continue } if (line.match(/^\s*$/)) { // blank lines break continuation - canContinue = false + cont = null continue } - - if (opt.multiline && canContinue && lastKey && lastKeyContainer && /^[ \t]/.test(line) && line.indexOf('=') === -1) { - lastKeyContainer[lastKey] += '\n' + line + if (cont && /^[ \t]/.test(line) && line.indexOf('=') === -1) { + const [target, slot] = cont + target[slot] += '\n' + line continue } + cont = null const match = line.match(re) if (!match) { continue @@ -197,11 +189,9 @@ const decode = (str, opt = {}) => { // not allowed // keep parsing the section, but don't attach it. p = Object.create(null) - canContinue = false continue } p = out[section] = out[section] || Object.create(null) - canContinue = false continue } const keyRaw = unsafe(match[2]) @@ -216,7 +206,6 @@ const decode = (str, opt = {}) => { ? keyRaw.slice(0, -2) : keyRaw if (key === '__proto__') { - canContinue = false continue } const valueRaw = match[3] ? unsafe(match[4]) : true @@ -238,19 +227,13 @@ const decode = (str, opt = {}) => { // array by accidentally forgetting the brackets if (Array.isArray(p[key])) { p[key].push(value) - canContinue = false - lastKey = null - lastKeyContainer = null + if (opt.multiline && typeof value === 'string') { + cont = [p[key], p[key].length - 1] + } } else { p[key] = value if (opt.multiline && typeof value === 'string') { - canContinue = true - lastKey = key - lastKeyContainer = p - } else { - canContinue = false - lastKey = null - lastKeyContainer = null + cont = [p, key] } } } diff --git a/tap-snapshots/test/foo.js.test.cjs b/tap-snapshots/test/foo.js.test.cjs index 11a3d61..6fc4f89 100644 --- a/tap-snapshots/test/foo.js.test.cjs +++ b/tap-snapshots/test/foo.js.test.cjs @@ -8,8 +8,8 @@ exports[`test/foo.js TAP decode from file > must match snapshot 1`] = ` Null Object { " xa n p ": String( - "r - oyoyor + "\\r + yoyoyo\\r\\r ), "[disturbing]": "hey you never know", @@ -58,13 +58,17 @@ Null Object { ), }, "array": Null Object { - "good": true, - "line2": true, - "line3": true, "list": Array [ "item1", - "line1", - "ok", + String( + line1 + line2 + \\tline3 + ), + String( + ok + good + ), String( good indented @@ -124,8 +128,8 @@ Null Object { exports[`test/foo.js TAP decode from file with multiline disabled > must match snapshot 1`] = ` Null Object { " xa n p ": String( - "r - oyoyor + "\\r + yoyoyo\\r\\r ), "[disturbing]": "hey you never know", @@ -234,9 +238,7 @@ Null Object { exports[`test/foo.js TAP encode from data > must match snapshot 1`] = ` o=p a with spaces=b c -" xa n p "="r - oyoyor - +" xa n p "="\\"\\r\\nyoyoyo\\r\\r\\n" "[disturbing]"=hey you never know s=something s1="something' @@ -312,21 +314,19 @@ d2=done [multiline.array] list[]=item1 list[]=line1 + line2 + line3 list[]=ok + good list[]=good indented -line2=true -line3=true -good=true ` exports[`test/foo.js TAP encode with align > must match snapshot 1`] = ` o = p a with spaces = b c -" xa n p " = "r - oyoyor - +" xa n p " = "\\"\\r\\nyoyoyo\\r\\r\\n" "[disturbing]" = hey you never know s = something s1 = "something' @@ -402,19 +402,17 @@ d2 = done [multiline.array] list[] = item1 list[] = line1 + line2 + line3 list[] = ok + good list[] = good indented -line2 = true -line3 = true -good = true ` exports[`test/foo.js TAP encode with align and sort > must match snapshot 1`] = ` -" xa n p " = "r - oyoyor - +" xa n p " = "\\"\\r\\nyoyoyo\\r\\r\\n" "[disturbing]" = hey you never know a with spaces = b c ar[] = one @@ -463,12 +461,12 @@ two = first second [multiline.array] -good = true -line2 = true -line3 = true list[] = item1 list[] = line1 + line2 + line3 list[] = ok + good list[] = good indented @@ -536,9 +534,7 @@ Array [ ` exports[`test/foo.js TAP encode with sort > must match snapshot 1`] = ` -" xa n p "="r - oyoyor - +" xa n p "="\\"\\r\\nyoyoyo\\r\\r\\n" "[disturbing]"=hey you never know a with spaces=b c ar[]=one @@ -587,12 +583,12 @@ two=first second [multiline.array] -good=true -line2=true -line3=true list[]=item1 list[]=line1 + line2 + line3 list[]=ok + good list[]=good indented diff --git a/test/fixtures/foo-multiline-error.ini b/test/fixtures/foo-multiline-error.ini index 2469eba..80e462a 100644 --- a/test/fixtures/foo-multiline-error.ini +++ b/test/fixtures/foo-multiline-error.ini @@ -1,6 +1,6 @@ -; This is the verbatim original value entry from foo.ini, which causes -; problems for multline roundtripping due to embedded newline without any -; following indent whistespace. +; The same entry as in foo.ini: a value with embedded newlines that cannot +; be written as indented continuation lines, so encode() JSON-quotes it and +; strictMultiline rejects it. ; wrap in quotes to JSON-decode and preserve spaces " xa n p " = "\"\r\nyoyoyo\r\r\n" diff --git a/test/fixtures/foo.ini b/test/fixtures/foo.ini index 47a8532..f3f50d5 100644 --- a/test/fixtures/foo.ini +++ b/test/fixtures/foo.ini @@ -3,10 +3,7 @@ o = p a with spaces = b c ; wrap in quotes to JSON-decode and preserve spaces -; (Original value modified so it can be encoded as indented continuation lines: -; a value with a carriage return, or with an unindented line after a newline, -; is not eligible. The original value lives on in foo-multiline-error.ini.) -" xa n p " = "\"r\n oyoyor\n" +" xa n p " = "\"\r\nyoyoyo\r\r\n" ; wrap in quotes to get a key with a bracket, not a section. "[disturbing]" = hey you never know diff --git a/test/foo.js b/test/foo.js index 4696dbf..a6bcd90 100644 --- a/test/foo.js +++ b/test/foo.js @@ -117,21 +117,62 @@ test('strict encode fails on problematic multiline entry', function (t) { t.end() }) -test('strict encode fails on array entry with unindented newline', function (t) { - t.throws(() => i.encode({ list: ['a\nb'] }), /Array entry value/) +// Every value here is one the verbatim continuation form cannot carry, so +// encode() falls back to JSON quoting: parse(stringify(x)) must still be x, +// and strictMultiline must reject the value instead. +const quoted = { + 'unindented line': ['line1\nline2', '"line1\\nline2"'], + 'carriage return': ['a\r\n b', '"a\\r\\n b"'], + 'blank line inside the value': ['x\n\n y', '"x\\n\\n y"'], + 'whitespace-only line': ['x\n \n y', '"x\\n \\n y"'], + 'trailing newline': ['x\n y\n', '"x\\n y\\n"'], + 'equals sign in a continuation line': ['x\n y=z', '"x\\n y=z"'], + 'comment-looking continuation line': ['x\n ;y', '"x\\n ;y"'], +} + +for (const [name, [value, expected]] of Object.entries(quoted)) { + test(`quoted fallback: ${name}`, function (t) { + const obj = { k: value, list: [value] } + const e = i.encode(obj) + t.same(e.split(/\r?\n/), [`k=${expected}`, `list[]=${expected}`, '']) + t.same(i.decode(e), obj, 'round trip') + t.throws(() => i.encode(obj, { strictMultiline: true }), /continuation lines/) + t.end() + }) +} + +// Values the verbatim form can carry, including a first line that needs +// escaping and array entries. +test('continuation lines round trip', function (t) { + const obj = { + semi: 'x;y\n z', + quoted: '"q"\n y', + empty: '\n x', + list: ['a\n b', 'c\n\td'], + } + const e = i.encode(obj) + t.same(e.split(/\r?\n/), [ + 'semi=x\\;y', ' z', + 'quoted="\\"q\\""', ' y', + 'empty=', ' x', + 'list[]=a', ' b', + 'list[]=c', '\td', + '', + ]) + t.same(i.decode(e), obj, 'round trip') t.end() }) -test('legacy encode quotes array entry with unindented newline', function (t) { - const e = i.encode({ list: ['a\nb'] }, { strictMultiline: false }) - t.same(e.split(/\r?\n/), ['list[]="a\\nb"', '']) +test('decode: what ends a continuation', function (t) { + t.same(i.decode('a=x\n y\n\n z'), { a: 'x\n y', z: true }, 'blank line') + t.same(i.decode('a=x\n y\n[s]\n z'), { a: 'x\n y', s: { z: true } }, 'section header') + t.same(i.decode('a=x\n y\n=junk\n z'), { a: 'x\n y', z: true }, 'unparseable line') + t.same(i.decode('a=x\n ; c\n y'), { a: 'x\n y' }, 'a comment does not') t.end() }) -test('carriage return is never written as a continuation line', function (t) { - const obj = { key: 'a\r\n b', list: ['c\r\n d'] } - t.throws(() => i.encode(obj), /carriage return/) - const e = i.encode(obj, { strictMultiline: false }) - t.same(e.split(/\r?\n/), ['key="a\\r\\n b"', 'list[]="c\\r\\n d"', '']) +test('decode: array entries continue', function (t) { + t.same(i.decode('a[]=x\n y\na[]=z\n w'), { a: ['x\n y', 'z\n w'] }) + t.same(i.decode('a=x\n y\na=z\n w', { bracketedArray: false }), { a: ['x\n y', 'z\n w'] }) t.end() })