diff --git a/CHANGELOG.md b/CHANGELOG.md index 3961246..49e0513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and nested arrays of tables: `[[a]]`, `b.c = 1`, `[[a]]`, `[a.b]` parses, as the TOML examples in the spec shape it. The records of the previous element falsely rejected the same paths in the next one. +- `UseLiteralMultiline` falls back to the escaped basic string when the value + cannot be carried verbatim by the literal form: a run of three single quotes, + a control character, or a lone carriage return. Such values previously + produced output that did not re-parse. - A float with an exponent marker but no digits (`1e`, `0.0E`) is rejected; the exponent requires at least one digit. - A date-time offset outside 00:00 through 23:59 is rejected; such offsets diff --git a/docs/API.md b/docs/API.md index 7718ed5..e2ff056 100644 --- a/docs/API.md +++ b/docs/API.md @@ -351,7 +351,10 @@ out, err := interpres.NewEncoder().UseLiteralMultiline(80).Marshal(cfg) ``` Single-line strings keep the basic form regardless of the threshold, and a -threshold of `0` or less disables the option. +threshold of `0` or less disables the option. A string the literal form cannot +carry verbatim (an embedded run of three single quotes, a control character +other than tab, or a carriage return outside a CRLF pair) also keeps the basic +form, so the output always re-parses to the same value. ### Cancellation diff --git a/encode.go b/encode.go index bf876dd..8537add 100644 --- a/encode.go +++ b/encode.go @@ -798,12 +798,40 @@ func (e *encoder) writeInlineTable(m map[string]any) error { } func (e *encoder) writeStringVal(s string) error { - if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && len(s) >= e.opts.literalMultilineAt { + if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && + len(s) >= e.opts.literalMultilineAt && canBeLiteralMultiline(s) { return writeLiteralMultilineString(&e.buf, s) } return writeQuotedString(&e.buf, s) } +// canBeLiteralMultiline reports whether s can be carried verbatim by the +// literal ”'...”' form: the form has no escapes, so a run of three single +// quotes would close the delimiter early, and control characters beyond tab, +// and a carriage return outside a CRLF pair, have no representation at all. +// Anything else falls back to the escaped basic string. +func canBeLiteralMultiline(s string) bool { + if strings.Contains(s, "'''") { + return false + } + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + switch { + case r == '\t' || r == '\n': + case r == '\r': + if !strings.HasPrefix(s[i+size:], "\n") { + return false + } + default: + if r < 0x20 || r == 0x7f { + return false + } + } + i += size + } + return true +} + // writeLiteralMultilineString writes s as a TOML literal multi-line string, // surrounded by triple single quotes. The opening delimiter is followed by a // newline that the reader trims, so we always include one. The closing diff --git a/encode_test.go b/encode_test.go index 8f03d52..adb4e18 100644 --- a/encode_test.go +++ b/encode_test.go @@ -267,6 +267,38 @@ func TestEncoderUseLiteralMultilineThresholdZero(t *testing.T) { } } +func TestEncoderLiteralMultilineFallsBackWhenUnsafe(t *testing.T) { + // The literal form carries the value verbatim, so content it cannot + // represent must fall back to the escaped basic string instead of + // producing output that does not re-parse. + cases := []struct { + name string + in string + }{ + {"embedded delimiter", "before ''' after\nsecond line"}, + {"control character", "a\x01b\nsecond"}, + {"delete character", "a\x7fb\nsecond"}, + {"lone carriage return", "first\rsecond\nthird"}, + } + for _, c := range cases { + out, err := NewEncoder().UseLiteralMultiline(5).Marshal(map[string]any{"s": c.in}) + if err != nil { + t.Fatalf("%s: marshal: %v", c.name, err) + } + if !bytes.HasPrefix(out, []byte("s = \"")) { + t.Errorf("%s: expected the basic quoted form, got:\n%s", c.name, out) + } + re, err := Parse(out) + if err != nil { + t.Errorf("%s: re-parse: %v\ndoc:\n%s", c.name, err, out) + continue + } + if re["s"] != c.in { + t.Errorf("%s: round-trip changed the value: %q", c.name, re["s"]) + } + } +} + // marshalerFunc adapts a plain function value to the Marshaler interface. // Tests use it to express "this field produces this TOML value" without a // dedicated struct definition.