fix(encode): fall back from the literal multiline form when unsafe

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-17 23:06:25 +02:00
parent feef4fe9ea
commit ec0d7a0023
4 changed files with 69 additions and 2 deletions
+4
View File
@@ -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 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 the TOML examples in the spec shape it. The records of the previous element
falsely rejected the same paths in the next one. 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; - A float with an exponent marker but no digits (`1e`, `0.0E`) is rejected;
the exponent requires at least one digit. the exponent requires at least one digit.
- A date-time offset outside 00:00 through 23:59 is rejected; such offsets - A date-time offset outside 00:00 through 23:59 is rejected; such offsets
+4 -1
View File
@@ -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 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 ### Cancellation
+29 -1
View File
@@ -798,12 +798,40 @@ func (e *encoder) writeInlineTable(m map[string]any) error {
} }
func (e *encoder) writeStringVal(s string) 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 writeLiteralMultilineString(&e.buf, s)
} }
return writeQuotedString(&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, // writeLiteralMultilineString writes s as a TOML literal multi-line string,
// surrounded by triple single quotes. The opening delimiter is followed by a // surrounded by triple single quotes. The opening delimiter is followed by a
// newline that the reader trims, so we always include one. The closing // newline that the reader trims, so we always include one. The closing
+32
View File
@@ -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. // marshalerFunc adapts a plain function value to the Marshaler interface.
// Tests use it to express "this field produces this TOML value" without a // Tests use it to express "this field produces this TOML value" without a
// dedicated struct definition. // dedicated struct definition.