From a89d4f58d4d9c441431e3431c3cf93a2ebb6815d Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Tue, 28 Jul 2026 17:06:46 +0200 Subject: [PATCH] file: escape written values the default reader would alter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WriteTo emitted a value surrounded by matching quotes ("…" or '…') verbatim, but the default reader strips surrounding quotes, so Load(WriteTo(v)) != v. The same round-trip broke for values wrapping an inner quote in whitespace and for values opening with """. Route these to the raw-string wrapper the reader already inverts, so a serialized value is read back byte-for-byte. Plain values are untouched. --- file.go | 11 ++++++++- file_test.go | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/file.go b/file.go index 58beabc..28664e0 100644 --- a/file.go +++ b/file.go @@ -463,8 +463,17 @@ func (f *File) writeToBuffer(indent string) (*bytes.Buffer, error) { val = `"""` + val + `"""` } else if !f.options.IgnoreInlineComment && strings.ContainsAny(val, "#;") { val = "`" + val + "`" + } else if hasSurroundedQuote(val, '"') || hasSurroundedQuote(val, '\'') || + strings.HasPrefix(val, `"""`) { + // Reader would unquote these or read them as a wrapper, so escape. + val = "`" + val + "`" } else if len(strings.TrimSpace(val)) != len(val) { - val = `"` + val + `"` + if strings.Contains(val, `"`) { + // Reader can't strip "..." with an inner quote; wrap raw instead. + val = "`" + val + "`" + } else { + val = `"` + val + `"` + } } if _, err := buf.WriteString(equalSign + val + LineBreak); err != nil { return false, err diff --git a/file_test.go b/file_test.go index 306c0c8..730aee3 100644 --- a/file_test.go +++ b/file_test.go @@ -535,3 +535,70 @@ v = 3 require.NoError(t, f.Reload()) assert.Equal(t, []string{"1", "2", "3"}, f.Section("slice").Key("v").ValueWithShadows()) } + +func TestFile_WriteTo_ValueRoundTrip(t *testing.T) { + t.Run("values survive the default reader byte-for-byte", func(t *testing.T) { + bt := "`" + cases := []struct { + name string + val string + }{ + // Surrounded by matching quotes: the reader would unquote these. + {"double-quoted", `"quoted"`}, + {"single-quoted", `'quoted'`}, + {"double-quoted empty", `""`}, + {"single-quoted empty", `''`}, + {"double-quoted spaces", `"a b"`}, + {"single-quoted spaces", `'a b'`}, + {"double-quoted number", `"123"`}, + {"single-quoted number", `'123'`}, + {"double-quoted equals", `"a=b"`}, + // Leading/trailing spaces around an inner quote break "..." wrapping. + {"space then quote", ` "x" `}, + {"quote then space", `"abc `}, + // A value opening with """ would be read as a multi-line wrapper. + {"triple-quote wrapped", `"""abc"""`}, + {"triple-quote prefix", `"""abc`}, + {"four quotes", `""""`}, + // No-regression: already-safe values must round-trip unchanged. + {"plain", "some value"}, + {"inner quote", `a"b`}, + {"quoted list", `"one", "two"`}, + {"trailing space", "abc "}, + {"three quotes", `"""`}, + {"backtick", "a" + bt + "b"}, + } + for _, c := range cases { + cfg := Empty() + cfg.Section("").Key("k").SetValue(c.val) + var buf bytes.Buffer + _, err := cfg.WriteTo(&buf) + require.NoError(t, err, c.name) + + rc, err := Load(buf.Bytes()) + require.NoError(t, err, "%s: reload of %q", c.name, buf.String()) + got := rc.Section("").Key("k").String() + assert.Equal(t, c.val, got, + "%s: round-trip mismatch\n in: %q\n ser: %q\n out: %q", + c.name, c.val, buf.String(), got) + } + }) + + t.Run("safe values are not over-quoted", func(t *testing.T) { + bt := "`" + cases := map[string]string{ + "some value": "k = some value\n", + `a"b`: "k = a\"b\n", + `"one", "two"`: "k = \"one\", \"two\"\n", + "a" + bt + "b": "k = \"\"\"a" + bt + "b\"\"\"\n", + } + for val, want := range cases { + cfg := Empty() + cfg.Section("").Key("k").SetValue(val) + var buf bytes.Buffer + _, err := cfg.WriteTo(&buf) + require.NoError(t, err, val) + assert.Equal(t, want, buf.String(), "value %q serialized form", val) + } + }) +}