v0.1.1 decode hardening, round trip support, doc and test update
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
This commit is contained in:
15 files changed
+871
-648
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
name: Test
|
||||||
|
on: [push, pull_request]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: false # Standard-library-only module has no go.sum.
|
||||||
|
- run: go test -race ./...
|
||||||
|
- run: GOARCH=386 go test ./...
|
||||||
|
- run: go vet ./...
|
||||||
|
- run: go test -run '^$' -fuzz '^FuzzRoundTrip$' -fuzztime=15s -parallel=2
|
||||||
|
- run: go test -run '^$' -fuzz '^FuzzIntegerConversions$' -fuzztime=10s -parallel=2
|
||||||
@@ -1,154 +1,106 @@
|
|||||||
# toml
|
# toml
|
||||||
|
|
||||||
Zero-dependency TOML encoder/decoder for Go.
|
A deterministic TOML subset encoder/decoder using only the standard library.
|
||||||
Implements a practical subset of TOML v1.0 with documented deviations.
|
Requires Go 1.27.1.
|
||||||
Standard library only.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
go get github.com/lixenwraith/toml
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Unmarshal
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
input := []byte(`
|
type Settings struct {
|
||||||
initial = "Idle"
|
Name string `toml:"name"`
|
||||||
|
Port uint16 `toml:"port"`
|
||||||
[states.Idle]
|
|
||||||
parent = "Root"
|
|
||||||
transitions = [
|
|
||||||
{ trigger = "Start", target = "Active" }
|
|
||||||
]
|
|
||||||
`)
|
|
||||||
|
|
||||||
type Transition struct {
|
|
||||||
Trigger string `toml:"trigger"`
|
|
||||||
Target string `toml:"target"`
|
|
||||||
Guard string `toml:"guard,omitempty"`
|
|
||||||
}
|
}
|
||||||
type State struct {
|
var settings Settings
|
||||||
Parent string `toml:"parent"`
|
err := toml.Unmarshal([]byte("name = \"service\"\nport = 8080\n"), &settings)
|
||||||
Transitions []Transition `toml:"transitions"`
|
data, err := toml.Marshal(settings)
|
||||||
}
|
|
||||||
type Config struct {
|
|
||||||
Initial string `toml:"initial"`
|
|
||||||
States map[string]*State `toml:"states"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var cfg Config
|
|
||||||
err := toml.Unmarshal(input, &cfg)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Marshal
|
`NewParser(data).Parse()` returns `map[string]any`. `Decode(data, &target)`
|
||||||
|
converts an already parsed value. Errors leave the destination unchanged.
|
||||||
|
Missing struct fields retain their existing values; present maps and slices
|
||||||
|
replace old containers. Existing pointer defaults are retained without writing
|
||||||
|
through the original pointer. Fixed arrays require an exact input length.
|
||||||
|
Tags use `toml:"name,omitempty"`; an empty tag name
|
||||||
|
falls back to the Go field name. Matching is case sensitive. Unknown input keys
|
||||||
|
and unexported fields are ignored.
|
||||||
|
|
||||||
```go
|
## Values and conversions
|
||||||
out, err := toml.Marshal(cfg) // deterministic: keys sorted alphabetically
|
|
||||||
```
|
|
||||||
|
|
||||||
### Decode
|
| TOML value | Parsed Go value | Typed destinations |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Integer | `int64`, on every architecture | Checked integer and float types |
|
||||||
|
| Float | `float64` | Float types; integers only when integral and in range |
|
||||||
|
| String | `string` | `string`, `any` |
|
||||||
|
| Boolean | `bool` | `bool`, `any` |
|
||||||
|
| Array | `[]any` | Slices, fixed arrays, `any` |
|
||||||
|
| Table | `map[string]any` | Structs, string-keyed maps, `any` |
|
||||||
|
| Array of tables | `[]map[string]any` | Slices and fixed arrays, including pointer elements |
|
||||||
|
|
||||||
`Decode(data any, v any)` maps an already-parsed `map[string]any` onto a
|
Direct `Decode` supports all signed/unsigned numeric widths and named numeric
|
||||||
target. `Unmarshal` = `Parse` + `Decode`.
|
types, including the full `uint64` range. Conversions reject overflow, negative
|
||||||
|
unsigned inputs, fractional-to-integer truncation, non-finite floats, and
|
||||||
|
integer-to-float precision loss. Float64-to-float32 rounding is allowed when in
|
||||||
|
range. Strings are not coerced to numbers by this package.
|
||||||
|
|
||||||
## Architecture
|
`Decode` is transactional, not a deep-copy or synchronization API. Interface
|
||||||
|
destinations retain input values (including map and slice references); unchanged
|
||||||
|
fields may retain existing references. A nil input is a no-op. Callers own
|
||||||
|
snapshot isolation and synchronization when values are shared between goroutines.
|
||||||
|
|
||||||
Unmarshal: []byte → Lexer → tokens → Parser → map[string]any → Decode (reflection) → target
|
TOML integer literals have a signed 64-bit range. Consequently `Marshal` rejects
|
||||||
Marshal: value → encoder (reflection, two-pass) → []byte
|
unsigned values above `MaxInt64`; callers may explicitly encode decimal strings
|
||||||
|
and interpret those in their application. Integer parsing and narrow destination
|
||||||
|
checks also work on 32-bit systems.
|
||||||
|
|
||||||
### Lexer (`lexer.go`)
|
## Encoding and round trips
|
||||||
|
|
||||||
- Single-pass, UTF-8 aware byte scanner with rune lookahead (`peekAt`).
|
`Marshal` accepts a struct or string-keyed map, including pointers to them. Keys
|
||||||
- Emits: ident, string, integer, float, bool, punctuation, newline, comment.
|
are sorted. Scalar fields precede nested tables. Table path segments are quoted
|
||||||
- Number/key disambiguation happens at the token level: `1.5` → Float,
|
individually, preserving dots, spaces, quotes and Unicode in keys. Mixed arrays
|
||||||
`a.b` → Ident Dot Ident, `1.2.3` → Error (multi-dot). Hex/octal/binary
|
and nested table values use inline tables when needed. Struct `omitempty` is
|
||||||
prefixes and exponent forms are validated during scanning.
|
honored; nil pointer/interface fields are omitted. Nil pointer/interface array
|
||||||
- Basic strings only. Escapes: `\"` `\\` `\n` `\t` `\r`. Unknown escape
|
elements error, because TOML has no null value. Nil maps and slices encode as
|
||||||
sequences are preserved verbatim (spec requires an error — see Deviations).
|
empty containers. Duplicate struct tag names error.
|
||||||
|
|
||||||
### Parser (`parser.go`)
|
Basic-string escapes emitted by the encoder (`\b`, `\f`, `\n`, `\r`, `\t`,
|
||||||
|
quotes, backslashes, Unicode escapes) decode symmetrically. Invalid UTF-8 and
|
||||||
|
unescaped control characters in strings are rejected. Encoding and typed decoding
|
||||||
|
have recursion bounds (1000 levels), so cyclic traversals fail instead of
|
||||||
|
overflowing the stack. Parsing bounds individual value nesting and dotted paths;
|
||||||
|
combining paths can still produce trees that exceed the encoder's bound.
|
||||||
|
|
||||||
- Recursive descent with a two-token window (`curToken`/`peekToken`).
|
Comments and original formatting are not preserved by `Parse`/`Marshal`. A
|
||||||
Comments are skipped during token advance.
|
configuration editor can scan the original bytes with `NewLexer` and retain
|
||||||
- Output model: tables → `map[string]any`, arrays → `[]any`,
|
`TokenComment.Literal` values, then prefix each with `#` and write them as a
|
||||||
arrays of tables → `[]map[string]any`.
|
preamble before the marshaled data. This preserves comment text (including
|
||||||
- Table headers (`[a.b]`) always resolve from the root; a `current` cursor
|
inline comments), but not its original location. Hashes inside strings are not
|
||||||
tracks the active table scope for subsequent key/value pairs.
|
comment tokens. Stop scanning on `TokenEOF` or `TokenError`.
|
||||||
- Dotted keys create intermediate maps. Traversal through an existing
|
|
||||||
`[[array]]` descends into its last element (TOML semantics).
|
|
||||||
- Conflict detection at assignment: duplicate keys, scalar-vs-table
|
|
||||||
redefinition, table-vs-array-of-tables shadowing. Numeric keys are
|
|
||||||
rejected in all positions (bare, quoted, dotted segments).
|
|
||||||
- Inline tables are immutable (later dotted keys or `[headers]` targeting them error); value nesting (arrays/inline tables) capped at 1000 levels.
|
|
||||||
|
|
||||||
### Decoder (`decode.go`)
|
## Deliberate subset and deviations
|
||||||
|
|
||||||
- Kind-switch reflection. Pointers are auto-allocated at any depth
|
- Datetime syntax is unsupported. Use quoted strings and parse them in the
|
||||||
(`******int` works). Nil maps and slices are materialized.
|
application; this package does not handle `time.Time` or custom marshal hooks.
|
||||||
- Struct fields match by `toml` tag first, then exact (case-sensitive)
|
- Literal strings and multiline strings, digit separators, and `inf`/`nan` are
|
||||||
field name. `toml:"-"` skips. Unexported fields are skipped.
|
unsupported.
|
||||||
- Numeric coercion: any parser-produced int/uint/float64 converts to the
|
- Decimal integer keys (including quoted keys, optional signs and values beyond
|
||||||
target numeric kind. Unknown keys in input are ignored.
|
int64) are forbidden. This restriction also applies to the encoder.
|
||||||
- Numeric decode errors on target overflow (`300` → `int8` errors); non-empty interface targets error unless assignable; unsupported target kinds (chan, func, complex, fixed arrays) error; `uint`/`uint64` sources above `MaxInt64` are rejected.
|
- Unknown string escapes are preserved verbatim as a documented relaxation.
|
||||||
|
- Explicit table headers may reopen existing tables. Inline tables cannot be
|
||||||
|
extended later.
|
||||||
|
- Inline tables allow newlines and trailing commas. Arrays require commas,
|
||||||
|
including between lines. Top-level statements require newline separation.
|
||||||
|
- Signed radix integers and uppercase radix prefixes are accepted extensions.
|
||||||
|
- Leading zeros are accepted in decimal numbers; `0123` means decimal 123,
|
||||||
|
not octal. Use `0o123` for octal.
|
||||||
|
- Embedded struct fields are not promoted. Custom marshal interfaces are not
|
||||||
|
supported. Decode input containers are the parser's generic maps/slices.
|
||||||
|
|
||||||
### Encoder (`encode.go`)
|
## Migration from the initial version
|
||||||
|
|
||||||
- Two-pass emission per table: scalars and inline arrays first, then
|
Parsed integers now use `int64`, not machine-sized `int`. Update type assertions
|
||||||
`[tables]` and `[[arrays of tables]]`. Guarantees keys are defined
|
on raw parser results. Typed struct destinations keep their declared types.
|
||||||
before sub-tables, i.e. output is always valid TOML.
|
Previously lossy numeric conversions and nil array elements now error. Mixed
|
||||||
- Keys sorted alphabetically → deterministic output for diffing/VCS.
|
arrays can now be encoded. All changes have regression and fuzz coverage.
|
||||||
- Bare-key validation mirrors the lexer's rules: keys that would lex as
|
|
||||||
numbers or booleans (`true`, `123a`, `-1x`) are quoted, so output always
|
|
||||||
re-parses (round-trip safe).
|
|
||||||
- `omitempty` honored; nil pointers and `toml:"-"` fields skipped;
|
|
||||||
root must be a struct or map.
|
|
||||||
- Floats use shortest `'g'` form at the source bit width (large magnitudes emit exponent notation); `NaN`/`±Inf` return an error; arrays mixing tables and scalars return an error (parser accepts them — encode-side limitation); struct keys sort by resolved tag name.
|
|
||||||
|
|
||||||
## Type Mapping
|
Run `go test -race ./...`, `GOARCH=386 go test ./...`, and, for example,
|
||||||
|
`go test -run '^$' -fuzz '^FuzzRoundTrip$' -fuzztime=30s`.
|
||||||
|
|
||||||
| TOML | Parser output (`map[string]any`) | Decode targets |
|
BSD-3-Clause; see LICENSE.
|
||||||
|-----------------|----------------------------------|-----------------------------------|
|
|
||||||
| string | `string` | `string`, `any` |
|
|
||||||
| integer | `int` | any int/uint kind, float kinds |
|
|
||||||
| float | `float64` | `float32`, `float64` |
|
|
||||||
| boolean | `bool` | `bool` |
|
|
||||||
| array | `[]any` | `[]T`, `any` |
|
|
||||||
| table | `map[string]any` | struct, `map[string]T`, `any` |
|
|
||||||
| array of tables | `[]map[string]any` | `[]T`, `[]*T` |
|
|
||||||
|
|
||||||
## Deviations from TOML v1.0
|
|
||||||
|
|
||||||
Intentional restrictions:
|
|
||||||
- Numeric keys are forbidden everywhere: `123 = 1`, `[456]`, `"789"`,
|
|
||||||
`a.1.b` all error. Stricter than spec.
|
|
||||||
|
|
||||||
Extensions (accepted, though spec forbids):
|
|
||||||
- Newlines inside inline tables (multi-line inline tables parse).
|
|
||||||
- Trailing commas in inline tables.
|
|
||||||
|
|
||||||
Relaxations:
|
|
||||||
- Unknown string escapes are preserved instead of erroring.
|
|
||||||
- Explicit table redefinition (`[a]` … `[a]`) is not rejected; the second
|
|
||||||
header reopens the table. Value/table conflicts are still caught.
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
Not supported:
|
|
||||||
- Date/time types (all four TOML forms). Workaround: store RFC 3339
|
|
||||||
strings and convert with `time.Parse` at the call site; strings
|
|
||||||
round-trip cleanly.
|
|
||||||
- Literal strings `'...'` and multi-line strings `"""..."""`, `'''...'''`.
|
|
||||||
- Underscore digit separators (`1_000`).
|
|
||||||
- Arrays mixing tables and scalar values are not encodable.
|
|
||||||
- `inf` / `nan` float literals.
|
|
||||||
- Custom marshaling interfaces (`Marshaler`/`Unmarshaler`,
|
|
||||||
`encoding.TextMarshaler`).
|
|
||||||
- Embedded struct field promotion in decode.
|
|
||||||
|
|
||||||
Platform note: TOML integers parse to Go `int`; 64-bit platforms assumed (amd64/arm64) and enforced at compile time (32-bit build fails).
|
|
||||||
Overflow of int64 during parse errors; 32-bit truncation of `int(int64)` is not guarded.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
BSD-3-Clause (see LICENSE).
|
|
||||||
+18
-16
@@ -2,6 +2,8 @@ package toml
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -71,7 +73,7 @@ func TestDecode_Uint64Wrap(t *testing.T) {
|
|||||||
if err := Decode(map[string]any{"v": uint64(math.MaxUint64)}, &tgt); err == nil {
|
if err := Decode(map[string]any{"v": uint64(math.MaxUint64)}, &tgt); err == nil {
|
||||||
t.Errorf("MaxUint64 into int64 must error, got %d", tgt.V)
|
t.Errorf("MaxUint64 into int64 must error, got %d", tgt.V)
|
||||||
}
|
}
|
||||||
if err := Decode(map[string]any{"v": uint(math.MaxUint64)}, &tgt); err == nil {
|
if err := Decode(map[string]any{"v": ^uint(0)}, &tgt); strconv.IntSize == 64 && err == nil {
|
||||||
t.Errorf("uint wrap into int64 must error, got %d", tgt.V)
|
t.Errorf("uint wrap into int64 must error, got %d", tgt.V)
|
||||||
}
|
}
|
||||||
if err := Decode(map[string]any{"v": uint64(42)}, &tgt); err != nil || tgt.V != 42 {
|
if err := Decode(map[string]any{"v": uint64(42)}, &tgt); err != nil || tgt.V != 42 {
|
||||||
@@ -118,23 +120,23 @@ func TestMarshal_NaNInf(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarshal_SliceHomogeneity(t *testing.T) {
|
func TestMarshal_MixedArraysAndNil(t *testing.T) {
|
||||||
if _, err := Marshal(map[string]any{"x": []any{map[string]any{"a": 1}, 2}}); err == nil {
|
input := map[string]any{"x": []any{map[string]any{"a": int64(1)}, int64(2)}}
|
||||||
t.Error("mixed table/scalar slice must error")
|
data, err := Marshal(input)
|
||||||
}
|
|
||||||
if _, err := Marshal(map[string]any{"x": []any{1, nil, 2}}); err == nil {
|
|
||||||
t.Error("nil interface element must error")
|
|
||||||
}
|
|
||||||
|
|
||||||
type Item struct {
|
|
||||||
N int `toml:"n"`
|
|
||||||
}
|
|
||||||
b, err := Marshal(map[string]any{"items": []*Item{nil, {N: 1}}})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("nil pointer in table slice must be skipped, not error: %v", err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if strings.Count(string(b), "[[items]]") != 1 {
|
var got map[string]any
|
||||||
t.Errorf("expected exactly one [[items]]:\n%s", b)
|
if err := Unmarshal(data, &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(input, got) {
|
||||||
|
t.Fatalf("got %#v", got)
|
||||||
|
}
|
||||||
|
for _, value := range []any{[]any{1, nil}, []*struct{ N int }{nil}} {
|
||||||
|
if _, err := Marshal(map[string]any{"x": value}); err == nil {
|
||||||
|
t.Fatal("nil array element accepted")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,18 +17,33 @@ func Unmarshal(data []byte, v any) error {
|
|||||||
return Decode(parsedMap, v)
|
return Decode(parsedMap, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode maps a generic map[string]any to a struct/slice/etc using reflection.
|
// Decode maps parser values to typed destinations using reflection.
|
||||||
// It prioritizes `toml` tags and falls back to field names.
|
// It uses toml tags, falling back to field names, and leaves v unchanged on error.
|
||||||
|
// Interface destinations retain the input value, including container references.
|
||||||
func Decode(data any, v any) error {
|
func Decode(data any, v any) error {
|
||||||
val := reflect.ValueOf(v)
|
val := reflect.ValueOf(v)
|
||||||
if val.Kind() != reflect.Ptr || val.IsNil() {
|
if val.Kind() != reflect.Ptr || val.IsNil() {
|
||||||
return fmt.Errorf("target must be a non-nil pointer")
|
return fmt.Errorf("target must be a non-nil pointer")
|
||||||
}
|
}
|
||||||
|
|
||||||
return decodeValue(data, val.Elem())
|
// Stage changes; pointer, map and slice branches allocate before writing.
|
||||||
|
next := reflect.New(val.Elem().Type()).Elem()
|
||||||
|
next.Set(val.Elem())
|
||||||
|
if err := decodeValue(data, next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
val.Elem().Set(next)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeValue(data any, val reflect.Value) error {
|
func decodeValue(data any, val reflect.Value) error {
|
||||||
|
return decodeAt(data, val, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeAt(data any, val reflect.Value, depth int) error {
|
||||||
|
if depth > maxValueDepth {
|
||||||
|
return fmt.Errorf("decode nesting exceeds %d", maxValueDepth)
|
||||||
|
}
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -37,7 +52,10 @@ func decodeValue(data any, val reflect.Value) error {
|
|||||||
case reflect.Ptr:
|
case reflect.Ptr:
|
||||||
elemType := val.Type().Elem()
|
elemType := val.Type().Elem()
|
||||||
newVal := reflect.New(elemType)
|
newVal := reflect.New(elemType)
|
||||||
if err := decodeValue(data, newVal.Elem()); err != nil {
|
if !val.IsNil() {
|
||||||
|
newVal.Elem().Set(val.Elem())
|
||||||
|
}
|
||||||
|
if err := decodeAt(data, newVal.Elem(), depth+1); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
val.Set(newVal)
|
val.Set(newVal)
|
||||||
@@ -47,9 +65,9 @@ func decodeValue(data any, val reflect.Value) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("expected map for struct, got %T", data)
|
return fmt.Errorf("expected map for struct, got %T", data)
|
||||||
}
|
}
|
||||||
return decodeStruct(dataMap, val)
|
return decodeStruct(dataMap, val, depth+1)
|
||||||
|
|
||||||
case reflect.Slice:
|
case reflect.Slice, reflect.Array:
|
||||||
dataSlice, ok := data.([]any)
|
dataSlice, ok := data.([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
if mapSlice, ok := data.([]map[string]any); ok {
|
if mapSlice, ok := data.([]map[string]any); ok {
|
||||||
@@ -62,10 +80,18 @@ func decodeValue(data any, val reflect.Value) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newSlice := reflect.MakeSlice(val.Type(), len(dataSlice), len(dataSlice))
|
var newSlice reflect.Value
|
||||||
|
if val.Kind() == reflect.Array {
|
||||||
|
if len(dataSlice) != val.Len() {
|
||||||
|
return fmt.Errorf("expected array length %d, got %d", val.Len(), len(dataSlice))
|
||||||
|
}
|
||||||
|
newSlice = reflect.New(val.Type()).Elem()
|
||||||
|
} else {
|
||||||
|
newSlice = reflect.MakeSlice(val.Type(), len(dataSlice), len(dataSlice))
|
||||||
|
}
|
||||||
for i := 0; i < len(dataSlice); i++ {
|
for i := 0; i < len(dataSlice); i++ {
|
||||||
if err := decodeValue(dataSlice[i], newSlice.Index(i)); err != nil {
|
if err := decodeAt(dataSlice[i], newSlice.Index(i), depth+1); err != nil {
|
||||||
return err
|
return fmt.Errorf("index %d: %w", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val.Set(newSlice)
|
val.Set(newSlice)
|
||||||
@@ -78,14 +104,14 @@ func decodeValue(data any, val reflect.Value) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("expected map, got %T", data)
|
return fmt.Errorf("expected map, got %T", data)
|
||||||
}
|
}
|
||||||
newMap := reflect.MakeMap(val.Type())
|
newMap := reflect.MakeMapWithSize(val.Type(), len(dataMap))
|
||||||
elemType := val.Type().Elem()
|
elemType := val.Type().Elem()
|
||||||
for k, vData := range dataMap {
|
for k, vData := range dataMap {
|
||||||
newVal := reflect.New(elemType).Elem()
|
newVal := reflect.New(elemType).Elem()
|
||||||
if err := decodeValue(vData, newVal); err != nil {
|
if err := decodeAt(vData, newVal, depth+1); err != nil {
|
||||||
return fmt.Errorf("map key %s: %w", k, err)
|
return fmt.Errorf("map key %s: %w", k, err)
|
||||||
}
|
}
|
||||||
newMap.SetMapIndex(reflect.ValueOf(k), newVal)
|
newMap.SetMapIndex(reflect.ValueOf(k).Convert(val.Type().Key()), newVal)
|
||||||
}
|
}
|
||||||
val.Set(newMap)
|
val.Set(newMap)
|
||||||
|
|
||||||
@@ -109,21 +135,19 @@ func decodeValue(data any, val reflect.Value) error {
|
|||||||
val.SetInt(i)
|
val.SetInt(i)
|
||||||
|
|
||||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
i, ok := toInt64(data)
|
u, ok := toUint64(data)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("cannot convert %T to uint", data)
|
// Formatting a rejected container's value could recurse through a cycle.
|
||||||
}
|
return fmt.Errorf("cannot convert %T to uint without loss", data)
|
||||||
if i < 0 {
|
|
||||||
return fmt.Errorf("cannot convert negative value %d to uint", i)
|
|
||||||
}
|
}
|
||||||
// Overflow check
|
// Overflow check
|
||||||
if val.OverflowUint(uint64(i)) {
|
if val.OverflowUint(u) {
|
||||||
return fmt.Errorf("value %d overflows %s", i, val.Type())
|
return fmt.Errorf("value %d overflows %s", u, val.Type())
|
||||||
}
|
}
|
||||||
val.SetUint(uint64(i))
|
val.SetUint(u)
|
||||||
|
|
||||||
case reflect.Float32, reflect.Float64:
|
case reflect.Float32, reflect.Float64:
|
||||||
f, ok := toFloat(data)
|
f, ok := toFloat(data, val.Type().Bits())
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("cannot convert %T to float", data)
|
return fmt.Errorf("cannot convert %T to float", data)
|
||||||
}
|
}
|
||||||
@@ -155,7 +179,7 @@ func decodeValue(data any, val reflect.Value) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeStruct(data map[string]any, val reflect.Value) error {
|
func decodeStruct(data map[string]any, val reflect.Value, depth int) error {
|
||||||
typ := val.Type()
|
typ := val.Type()
|
||||||
|
|
||||||
for i := 0; i < val.NumField(); i++ {
|
for i := 0; i < val.NumField(); i++ {
|
||||||
@@ -174,12 +198,14 @@ func decodeStruct(data map[string]any, val reflect.Value) error {
|
|||||||
if parts[0] == "-" {
|
if parts[0] == "-" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key = parts[0]
|
if parts[0] != "" {
|
||||||
|
key = parts[0]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up in data map (case sensitive)
|
// Look up in data map (case sensitive)
|
||||||
if vData, ok := data[key]; ok {
|
if vData, ok := data[key]; ok {
|
||||||
if err := decodeValue(vData, field); err != nil {
|
if err := decodeAt(vData, field, depth+1); err != nil {
|
||||||
return fmt.Errorf("%s.%s: %w", typ.Name(), fieldType.Name, err)
|
return fmt.Errorf("%s.%s: %w", typ.Name(), fieldType.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,67 +213,79 @@ func decodeStruct(data map[string]any, val reflect.Value) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// toInt64 converts numeric types to int64
|
// Numeric conversions check before conversion: rounded float64(MaxInt64) is
|
||||||
|
// 2^63, so the upper bound must be exclusive. No float intermediary for integers.
|
||||||
func toInt64(v any) (int64, bool) {
|
func toInt64(v any) (int64, bool) {
|
||||||
switch i := v.(type) {
|
r := reflect.ValueOf(v)
|
||||||
case int:
|
if !r.IsValid() {
|
||||||
return int64(i), true
|
return 0, false
|
||||||
case int8:
|
}
|
||||||
return int64(i), true
|
switch r.Kind() {
|
||||||
case int16:
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
return int64(i), true
|
return r.Int(), true
|
||||||
case int32:
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
return int64(i), true
|
u := r.Uint()
|
||||||
case int64:
|
return int64(u), u <= math.MaxInt64
|
||||||
return i, true
|
case reflect.Float32, reflect.Float64:
|
||||||
case uint:
|
f := r.Float()
|
||||||
// Reject wrap
|
if math.IsNaN(f) || f < -0x1p63 || f >= 0x1p63 || math.Trunc(f) != f {
|
||||||
if uint64(i) > math.MaxInt64 {
|
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
case uint8:
|
return int64(f), true
|
||||||
return int64(i), true
|
|
||||||
case uint16:
|
|
||||||
return int64(i), true
|
|
||||||
case uint32:
|
|
||||||
return int64(i), true
|
|
||||||
case uint64:
|
|
||||||
// Reject values that wrap negative through int64
|
|
||||||
if i > math.MaxInt64 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return int64(i), true
|
|
||||||
case float64:
|
|
||||||
return int64(i), true
|
|
||||||
}
|
}
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func toFloat(v any) (float64, bool) {
|
func toUint64(v any) (uint64, bool) {
|
||||||
switch i := v.(type) {
|
r := reflect.ValueOf(v)
|
||||||
case int:
|
if !r.IsValid() {
|
||||||
return float64(i), true
|
return 0, false
|
||||||
case int8:
|
}
|
||||||
return float64(i), true
|
switch r.Kind() {
|
||||||
case int16:
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
return float64(i), true
|
return r.Uint(), true
|
||||||
case int32:
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
return float64(i), true
|
i := r.Int()
|
||||||
case int64:
|
return uint64(i), i >= 0
|
||||||
return float64(i), true
|
case reflect.Float32, reflect.Float64:
|
||||||
case uint:
|
f := r.Float()
|
||||||
return float64(i), true
|
if math.IsNaN(f) || f < 0 || f >= 0x1p64 || math.Trunc(f) != f {
|
||||||
case uint8:
|
return 0, false
|
||||||
return float64(i), true
|
}
|
||||||
case uint16:
|
return uint64(f), true
|
||||||
return float64(i), true
|
|
||||||
case uint32:
|
|
||||||
return float64(i), true
|
|
||||||
case uint64:
|
|
||||||
return float64(i), true
|
|
||||||
case float64:
|
|
||||||
return i, true
|
|
||||||
}
|
}
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toFloat(v any, width int) (float64, bool) {
|
||||||
|
r := reflect.ValueOf(v)
|
||||||
|
if !r.IsValid() {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var f float64
|
||||||
|
switch r.Kind() {
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
i := r.Int()
|
||||||
|
f = float64(i)
|
||||||
|
if width == 32 {
|
||||||
|
f = float64(float32(f))
|
||||||
|
}
|
||||||
|
if f >= 0x1p63 || f < -0x1p63 || int64(f) != i {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
u := r.Uint()
|
||||||
|
f = float64(u)
|
||||||
|
if width == 32 {
|
||||||
|
f = float64(float32(f))
|
||||||
|
}
|
||||||
|
if f >= 0x1p64 || uint64(f) != u {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
f = r.Float()
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f, !math.IsNaN(f) && !math.IsInf(f, 0)
|
||||||
|
}
|
||||||
+1
-1
@@ -308,7 +308,7 @@ transitions = [
|
|||||||
t.Error("GuardArgs is nil")
|
t.Error("GuardArgs is nil")
|
||||||
} else if ms, ok := grw.Transitions[0].GuardArgs["ms"]; !ok {
|
} else if ms, ok := grw.Transitions[0].GuardArgs["ms"]; !ok {
|
||||||
t.Error("GuardArgs missing 'ms' key")
|
t.Error("GuardArgs missing 'ms' key")
|
||||||
} else if msInt, ok := ms.(int); !ok || msInt != 2000 {
|
} else if msInt, ok := ms.(int64); !ok || msInt != 2000 {
|
||||||
t.Errorf("GuardArgs.ms mismatch: %T %v", ms, ms)
|
t.Errorf("GuardArgs.ms mismatch: %T %v", ms, ms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Configuration integration audit
|
||||||
|
|
||||||
|
The audit fixes concrete failures in the original release:
|
||||||
|
|
||||||
|
- Numeric conversion truncated fractions, accepted float values outside integer
|
||||||
|
range, rejected ordinary `uint` input, and could not decode full `uint64`.
|
||||||
|
Checked conversions now operate in the source integer domain and reject loss.
|
||||||
|
- Parser integers depended on 64-bit `int`. They are now explicitly `int64`;
|
||||||
|
narrowed destinations retain overflow checks on every architecture.
|
||||||
|
- Failed decoding could partially overwrite a destination; decoding now commits
|
||||||
|
only after success. Named string map keys no longer panic, and tags with an
|
||||||
|
empty name correctly fall back to the field name. Existing pointer defaults
|
||||||
|
survive updates, and errors do not mutate the original pointee or containers.
|
||||||
|
- Fixed arrays could be encoded but not decoded. They now decode with exact
|
||||||
|
length and element-range checks, including nested arrays and arrays of tables.
|
||||||
|
- Nested table headers failed to quote their component keys. Arrays of interface
|
||||||
|
table values could encode empty tables. Mixed/nested arrays now encode using
|
||||||
|
inline tables, and nested keys use the same escaping as scalar keys.
|
||||||
|
- Backspace/form-feed strings did not decode the escapes emitted by Marshal.
|
||||||
|
Invalid UTF-8, raw string controls, missing array/inline-table commas and
|
||||||
|
missing statement separators are rejected.
|
||||||
|
- Cyclic encoding could overflow the stack. Encoding/decoding and dotted paths
|
||||||
|
now have depth limits. Numeric keys and unsupported uint64 literals fail
|
||||||
|
explicitly instead of generating unreadable output. Nil array elements error.
|
||||||
|
- Numeric key checks now cover decimal keys of any length, including values
|
||||||
|
beyond int64; the parser and encoder apply the same restriction.
|
||||||
|
- Formatting an invalid unsigned input could recurse through a cyclic map.
|
||||||
|
Conversion errors now report the source type without formatting its contents.
|
||||||
|
- Map decoding and encoder entry collection preallocate from known sizes. All
|
||||||
|
operation state stays local; no global cache or lock was added.
|
||||||
|
|
||||||
|
No datetime, custom marshal interface, literal-string or multiline-string
|
||||||
|
feature was added. The supported subset and remaining deviations are in README.
|
||||||
|
|
||||||
|
This branch consolidates the prior `audit/numeric-hardening-20260928` and
|
||||||
|
`audit/config-roundtrip-hardening` commits, retaining both in its ancestry.
|
||||||
|
Only this repository is changed. Consumer migration, watcher publication and
|
||||||
|
snapshot ownership remain work for the separate config audit.
|
||||||
|
|
||||||
|
Verification for this pass: unit and race suites and `go vet` pass on Go 1.27.1;
|
||||||
|
the 386 test binary cross-compiles. This host cannot execute 386 binaries, so CI
|
||||||
|
executes that suite. The first CI run caught a test fixture that cast an int64
|
||||||
|
through machine-sized int before Decode; that fixture now retains its int64.
|
||||||
|
Regression tests for fixed arrays, pointer defaults and oversized numeric keys
|
||||||
|
were also run against recovered commit `6384895` and reproduced the failures.
|
||||||
|
|
||||||
|
The 15-second round-trip fuzz run passed 71,221 executions. Its property now
|
||||||
|
fails on unexpected Marshal errors instead of silently skipping them; only the
|
||||||
|
documented whole-tree depth bound is excluded. Integer fuzzing covers full
|
||||||
|
uint64 preservation, signed boundaries and uint8 narrowing. CI runs these bounded
|
||||||
|
fuzz checks on every push and pull request. Refer to the PR checks for final CI
|
||||||
|
results.
|
||||||
|
|
||||||
|
Integration limits: parsed integers are int64; raw assertions must migrate.
|
||||||
|
Direct Decode can retain the full uint64 range, but file encoding rejects values
|
||||||
|
above MaxInt64. Interface destinations retain source references, so config must
|
||||||
|
own its snapshots. Comment text can be collected with the existing lexer and
|
||||||
|
written as a preamble; Marshal does not preserve source positions. README
|
||||||
|
documents these contracts and the intentionally limited TOML syntax.
|
||||||
@@ -2,460 +2,304 @@ package toml
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Marshal returns the TOML encoding of v
|
// Marshal encodes a struct or string-keyed map with deterministic key order.
|
||||||
//
|
// Nil pointer/interface fields are omitted; nil pointer/interface array elements,
|
||||||
// Marshal supports struct and map types as the root object
|
// cycles/excessive nesting,
|
||||||
// It follows standard TOML formatting:
|
// non-finite floats, and integers outside TOML's signed 64-bit range error.
|
||||||
// - Comments and whitespace are not preserved from original decoding
|
// Comments and whitespace are not retained. Datetimes are not supported.
|
||||||
// - Dates are not supported (as per requirements).
|
|
||||||
// - Nil pointers are skipped
|
|
||||||
// - Unexported fields are skipped
|
|
||||||
// - Fields with `omitempty` are skipped if zero
|
|
||||||
// - Struct fields/Map keys are sorted alphabetically for determinism
|
|
||||||
// - Fully numerical keys are rejected
|
|
||||||
func Marshal(v any) ([]byte, error) {
|
func Marshal(v any) ([]byte, error) {
|
||||||
val := reflect.ValueOf(v)
|
var e encoder
|
||||||
|
if err := e.table(reflect.ValueOf(v), "", 0); err != nil {
|
||||||
// Dereference pointer if necessary
|
|
||||||
if val.Kind() == reflect.Ptr {
|
|
||||||
if val.IsNil() {
|
|
||||||
return nil, fmt.Errorf("marshal: cannot marshal nil pointer")
|
|
||||||
}
|
|
||||||
val = val.Elem()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Root must be a Table (Struct or Map)
|
|
||||||
if val.Kind() != reflect.Struct && val.Kind() != reflect.Map {
|
|
||||||
return nil, fmt.Errorf("marshal: root must be struct or map, got %v", val.Kind())
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
enc := &encoder{w: buf}
|
|
||||||
|
|
||||||
if err := enc.encodeTable(val, ""); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return e.buf.Bytes(), nil
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type encoder struct {
|
type encoder struct{ buf bytes.Buffer }
|
||||||
w *bytes.Buffer
|
type entry struct {
|
||||||
|
key string
|
||||||
|
value reflect.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodeTable writes the fields of a struct or map.
|
func indirect(v reflect.Value) (reflect.Value, error) {
|
||||||
// It uses a two-pass approach:
|
for depth := 0; v.IsValid() && (v.Kind() == reflect.Interface || v.Kind() == reflect.Pointer); depth++ {
|
||||||
// 1. Write all scalar values (primitives, inline arrays).
|
if depth > maxValueDepth {
|
||||||
// 2. Recurse and write nested tables (structs, maps, arrays of tables).
|
return reflect.Value{}, fmt.Errorf("pointer nesting exceeds %d", maxValueDepth)
|
||||||
// This ensures valid TOML where keys are defined before sub-tables.
|
}
|
||||||
func (e *encoder) encodeTable(rv reflect.Value, prefix string) error {
|
if v.IsNil() {
|
||||||
// 1. Gather keys
|
return reflect.Value{}, nil
|
||||||
keys, err := e.getSortedKeys(rv)
|
}
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func entries(v reflect.Value) ([]entry, error) {
|
||||||
|
v, err := indirect(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !v.IsValid() {
|
||||||
|
return nil, fmt.Errorf("nil table")
|
||||||
|
}
|
||||||
|
var out []entry
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.Map:
|
||||||
|
if v.Type().Key().Kind() != reflect.String {
|
||||||
|
return nil, fmt.Errorf("map keys must be strings")
|
||||||
|
}
|
||||||
|
out = make([]entry, 0, v.Len())
|
||||||
|
it := v.MapRange()
|
||||||
|
for it.Next() {
|
||||||
|
out = append(out, entry{it.Key().String(), it.Value()})
|
||||||
|
}
|
||||||
|
case reflect.Struct:
|
||||||
|
t := v.Type()
|
||||||
|
out = make([]entry, 0, t.NumField())
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
f := t.Field(i)
|
||||||
|
if !f.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(f.Tag.Get("toml"), ",")
|
||||||
|
if parts[0] == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := f.Name
|
||||||
|
if parts[0] != "" {
|
||||||
|
key = parts[0]
|
||||||
|
}
|
||||||
|
if slices.Contains(parts[1:], "omitempty") && isEmptyValue(v.Field(i)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, entry{key, v.Field(i)})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("expected struct or map, got %s", v.Kind())
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b entry) int { return cmp.Compare(a.key, b.key) })
|
||||||
|
result := out[:0]
|
||||||
|
previous := ""
|
||||||
|
for i, x := range out {
|
||||||
|
if _, err := keyText(x.key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if i > 0 && previous == x.key {
|
||||||
|
return nil, fmt.Errorf("duplicate TOML key %q", x.key)
|
||||||
|
}
|
||||||
|
previous = x.key
|
||||||
|
value, err := indirect(x.value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if value.IsValid() {
|
||||||
|
result = append(result, entry{x.key, value})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *encoder) table(v reflect.Value, prefix string, depth int) error {
|
||||||
|
if depth > maxValueDepth {
|
||||||
|
return fmt.Errorf("encode nesting exceeds %d (possible cycle)", maxValueDepth)
|
||||||
|
}
|
||||||
|
fields, err := entries(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
var tables []entry
|
||||||
// 2. Separation: Identify which keys are scalars (printed now) vs tables (printed later)
|
for _, x := range fields {
|
||||||
var scalars []string
|
if isTable(x.value) || isTableArray(x.value) {
|
||||||
var tables []string
|
tables = append(tables, x)
|
||||||
|
|
||||||
for _, k := range keys {
|
|
||||||
fieldVal := e.resolveValue(rv, k)
|
|
||||||
if !fieldVal.IsValid() {
|
|
||||||
continue // Skip invalid/nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should skip (omitempty, unexported handled in getSortedKeys)
|
|
||||||
if e.shouldSkip(rv, k, fieldVal) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
key, _ := keyText(x.key)
|
||||||
// isTable validates slice homogeneity
|
e.buf.WriteString(key + " = ")
|
||||||
isTab, err := e.isTable(fieldVal)
|
if err := e.value(x.value, depth+1); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("key %q: %w", x.key, err)
|
||||||
return fmt.Errorf("key %q: %w", e.getKeyName(rv, k), err)
|
|
||||||
}
|
|
||||||
if isTab {
|
|
||||||
tables = append(tables, k)
|
|
||||||
} else {
|
|
||||||
scalars = append(scalars, k)
|
|
||||||
}
|
}
|
||||||
|
e.buf.WriteByte('\n')
|
||||||
}
|
}
|
||||||
|
for _, x := range tables {
|
||||||
// 3. Pass 1: Write Scalars
|
key, _ := keyText(x.key)
|
||||||
for _, k := range scalars {
|
|
||||||
val := e.resolveValue(rv, k)
|
|
||||||
keyName := e.getKeyName(rv, k)
|
|
||||||
|
|
||||||
if err := e.writeKey(keyName); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := e.w.WriteString(" = "); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := e.encodeValue(val); err != nil {
|
|
||||||
return fmt.Errorf("key %q: %w", keyName, err)
|
|
||||||
}
|
|
||||||
e.w.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Pass 2: Write Tables
|
|
||||||
for _, k := range tables {
|
|
||||||
val := e.resolveValue(rv, k)
|
|
||||||
keyName := e.getKeyName(rv, k)
|
|
||||||
|
|
||||||
// Determine full path for header
|
|
||||||
fullKey := keyName
|
|
||||||
if prefix != "" {
|
if prefix != "" {
|
||||||
fullKey = prefix + "." + keyName
|
key = prefix + "." + key
|
||||||
}
|
}
|
||||||
|
if isTable(x.value) {
|
||||||
// Handle specific table types
|
e.buf.WriteString("\n[" + key + "]\n")
|
||||||
switch val.Kind() {
|
if err := e.table(x.value, key, depth+1); err != nil {
|
||||||
case reflect.Struct, reflect.Map:
|
|
||||||
// [header]
|
|
||||||
e.w.WriteString("\n")
|
|
||||||
e.w.WriteString("[" + fullKey + "]\n")
|
|
||||||
if err := e.encodeTable(val, fullKey); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
case reflect.Slice, reflect.Array:
|
for i := 0; i < x.value.Len(); i++ {
|
||||||
// [[header]]
|
e.buf.WriteString("\n[[" + key + "]]\n")
|
||||||
for i := 0; i < val.Len(); i++ {
|
if err := e.table(x.value.Index(i), key, depth+1); err != nil {
|
||||||
elem := val.Index(i)
|
|
||||||
// Dereference pointer elements in slice
|
|
||||||
if elem.Kind() == reflect.Ptr {
|
|
||||||
if elem.IsNil() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
elem = elem.Elem()
|
|
||||||
}
|
|
||||||
|
|
||||||
e.w.WriteString("\n")
|
|
||||||
e.w.WriteString("[[" + fullKey + "]]\n")
|
|
||||||
if err := e.encodeTable(elem, fullKey); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodeValue writes a single primitive value or inline array
|
func isTable(v reflect.Value) bool { return v.Kind() == reflect.Map || v.Kind() == reflect.Struct }
|
||||||
func (e *encoder) encodeValue(v reflect.Value) error {
|
func isTableArray(v reflect.Value) bool {
|
||||||
|
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array || v.Len() == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < v.Len(); i++ {
|
||||||
|
x, err := indirect(v.Index(i))
|
||||||
|
if err != nil || !x.IsValid() || !isTable(x) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *encoder) value(v reflect.Value, depth int) error {
|
||||||
|
if depth > maxValueDepth {
|
||||||
|
return fmt.Errorf("encode nesting exceeds %d (possible cycle)", maxValueDepth)
|
||||||
|
}
|
||||||
|
v, err := indirect(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !v.IsValid() {
|
||||||
|
return fmt.Errorf("nil array element has no TOML representation")
|
||||||
|
}
|
||||||
switch v.Kind() {
|
switch v.Kind() {
|
||||||
case reflect.Bool:
|
case reflect.Bool:
|
||||||
if v.Bool() {
|
e.buf.WriteString(strconv.FormatBool(v.Bool()))
|
||||||
e.w.WriteString("true")
|
|
||||||
} else {
|
|
||||||
e.w.WriteString("false")
|
|
||||||
}
|
|
||||||
|
|
||||||
case reflect.String:
|
case reflect.String:
|
||||||
e.encodeString(v.String())
|
if !utf8.ValidString(v.String()) {
|
||||||
|
return fmt.Errorf("invalid UTF-8 string")
|
||||||
|
}
|
||||||
|
e.string(v.String())
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
e.w.WriteString(strconv.FormatInt(v.Int(), 10))
|
e.buf.WriteString(strconv.FormatInt(v.Int(), 10))
|
||||||
|
|
||||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
e.w.WriteString(strconv.FormatUint(v.Uint(), 10))
|
if v.Uint() > math.MaxInt64 {
|
||||||
|
return fmt.Errorf("unsigned integer %d exceeds TOML int64 range", v.Uint())
|
||||||
|
}
|
||||||
|
e.buf.WriteString(strconv.FormatUint(v.Uint(), 10))
|
||||||
case reflect.Float32, reflect.Float64:
|
case reflect.Float32, reflect.Float64:
|
||||||
f := v.Float()
|
f := v.Float()
|
||||||
// NaN/Inf have no TOML representation; emitting them produces
|
|
||||||
// output the parser rejects
|
|
||||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||||
return fmt.Errorf("cannot encode %v as TOML float", f)
|
return fmt.Errorf("non-finite float")
|
||||||
}
|
}
|
||||||
// bitSize matches kind (float32 emitted noise digits);
|
s := strconv.FormatFloat(f, 'g', -1, v.Type().Bits())
|
||||||
// 'g' avoids ~100-digit 'f' expansions for large exponents
|
if !strings.ContainsAny(s, ".eE") {
|
||||||
bits := 64
|
s += ".0"
|
||||||
if v.Kind() == reflect.Float32 {
|
|
||||||
bits = 32
|
|
||||||
}
|
}
|
||||||
str := strconv.FormatFloat(f, 'g', -1, bits)
|
e.buf.WriteString(s)
|
||||||
if !strings.ContainsAny(str, ".eE") {
|
|
||||||
str += ".0"
|
|
||||||
}
|
|
||||||
e.w.WriteString(str)
|
|
||||||
|
|
||||||
case reflect.Slice, reflect.Array:
|
case reflect.Slice, reflect.Array:
|
||||||
// Inline array: [1, 2, "3"]
|
e.buf.WriteByte('[')
|
||||||
e.w.WriteString("[")
|
|
||||||
for i := 0; i < v.Len(); i++ {
|
for i := 0; i < v.Len(); i++ {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
e.w.WriteString(", ")
|
e.buf.WriteString(", ")
|
||||||
}
|
}
|
||||||
if err := e.encodeValue(v.Index(i)); err != nil {
|
if err := e.value(v.Index(i), depth+1); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
e.w.WriteString("]")
|
e.buf.WriteByte(']')
|
||||||
|
case reflect.Struct, reflect.Map:
|
||||||
case reflect.Interface:
|
fields, err := entries(v)
|
||||||
if v.IsNil() {
|
if err != nil {
|
||||||
return nil // Should be handled by caller usually
|
return err
|
||||||
}
|
}
|
||||||
return e.encodeValue(v.Elem())
|
e.buf.WriteByte('{')
|
||||||
|
for i, x := range fields {
|
||||||
|
if i > 0 {
|
||||||
|
e.buf.WriteString(", ")
|
||||||
|
}
|
||||||
|
key, _ := keyText(x.key)
|
||||||
|
e.buf.WriteString(key + " = ")
|
||||||
|
if err := e.value(x.value, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.buf.WriteByte('}')
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported type: %v", v.Kind())
|
return fmt.Errorf("unsupported type %s", v.Type())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
func keyText(s string) (string, error) {
|
||||||
|
if !utf8.ValidString(s) {
|
||||||
// getSortedKeys returns all field names (struct) or keys (map) sorted
|
return "", fmt.Errorf("invalid UTF-8 key")
|
||||||
func (e *encoder) getSortedKeys(rv reflect.Value) ([]string, error) {
|
|
||||||
var keys []string
|
|
||||||
|
|
||||||
if rv.Kind() == reflect.Map {
|
|
||||||
for _, key := range rv.MapKeys() {
|
|
||||||
if key.Kind() != reflect.String {
|
|
||||||
return nil, fmt.Errorf("map key must be string, got %v", key.Kind())
|
|
||||||
}
|
|
||||||
keys = append(keys, key.String())
|
|
||||||
}
|
|
||||||
} else if rv.Kind() == reflect.Struct {
|
|
||||||
typ := rv.Type()
|
|
||||||
for i := 0; i < rv.NumField(); i++ {
|
|
||||||
field := typ.Field(i)
|
|
||||||
// Skip unexported
|
|
||||||
if field.PkgPath != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Skip if tag is "-"
|
|
||||||
tag := field.Tag.Get("toml")
|
|
||||||
if tag == "-" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keys = append(keys, field.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Sort by emitted key (tag-resolved), not Go field name.
|
if numericKey(s) {
|
||||||
// Identity for maps (getKeyName returns the key itself).
|
return "", fmt.Errorf("numeric keys are forbidden: %q", s)
|
||||||
sort.Slice(keys, func(i, j int) bool {
|
}
|
||||||
return e.getKeyName(rv, keys[i]) < e.getKeyName(rv, keys[j])
|
if isBareKey(s) {
|
||||||
})
|
return s, nil
|
||||||
return keys, nil
|
}
|
||||||
|
var e encoder
|
||||||
|
e.string(s)
|
||||||
|
return e.buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveValue extracts the value from struct field or map key
|
func (e *encoder) string(s string) {
|
||||||
// It also handles interface unwrapping for map[string]any.
|
e.buf.WriteByte('"')
|
||||||
func (e *encoder) resolveValue(container reflect.Value, key string) reflect.Value {
|
for _, r := range s {
|
||||||
var val reflect.Value
|
switch r {
|
||||||
if container.Kind() == reflect.Map {
|
case '"':
|
||||||
val = container.MapIndex(reflect.ValueOf(key))
|
e.buf.WriteString(`\"`)
|
||||||
} else {
|
case '\\':
|
||||||
val = container.FieldByName(key)
|
e.buf.WriteString(`\\`)
|
||||||
}
|
case '\n':
|
||||||
|
e.buf.WriteString(`\n`)
|
||||||
// Unwrap interface if needed
|
case '\r':
|
||||||
if val.Kind() == reflect.Interface && !val.IsNil() {
|
e.buf.WriteString(`\r`)
|
||||||
val = val.Elem()
|
case '\t':
|
||||||
}
|
e.buf.WriteString(`\t`)
|
||||||
|
case '\b':
|
||||||
// Dereference pointer if needed (but keep nil ptrs for checking)
|
e.buf.WriteString(`\b`)
|
||||||
if val.Kind() == reflect.Ptr && !val.IsNil() {
|
case '\f':
|
||||||
val = val.Elem()
|
e.buf.WriteString(`\f`)
|
||||||
}
|
default:
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
return val
|
fmt.Fprintf(&e.buf, `\u%04X`, r)
|
||||||
}
|
} else {
|
||||||
|
e.buf.WriteRune(r)
|
||||||
// getKeyName resolves the TOML key name (handles struct tags)
|
}
|
||||||
// For maps, the key name is the key itself
|
|
||||||
func (e *encoder) getKeyName(container reflect.Value, realName string) string {
|
|
||||||
if container.Kind() == reflect.Map {
|
|
||||||
return realName
|
|
||||||
}
|
|
||||||
// Struct: lookup tag
|
|
||||||
field, _ := container.Type().FieldByName(realName)
|
|
||||||
tag := field.Tag.Get("toml")
|
|
||||||
if tag != "" {
|
|
||||||
parts := strings.Split(tag, ",")
|
|
||||||
if parts[0] != "" {
|
|
||||||
return parts[0]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return realName
|
e.buf.WriteByte('"')
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldSkip returns true if the field should be omitted (nil ptr, omitempty)
|
func isBareKey(s string) bool {
|
||||||
func (e *encoder) shouldSkip(container reflect.Value, realName string, val reflect.Value) bool {
|
if s == "" || s == "true" || s == "false" {
|
||||||
// Skip nil pointers / interfaces
|
|
||||||
if (val.Kind() == reflect.Ptr || val.Kind() == reflect.Interface) && val.IsNil() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maps don't have tags, so we only skip nil values
|
|
||||||
if container.Kind() == reflect.Map {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if s[0] >= '0' && s[0] <= '9' || len(s) > 1 && s[0] == '-' && s[1] >= '0' && s[1] <= '9' {
|
||||||
// Structs: check omitempty
|
return false
|
||||||
field, _ := container.Type().FieldByName(realName)
|
|
||||||
tag := field.Tag.Get("toml")
|
|
||||||
if strings.Contains(tag, "omitempty") && isEmptyValue(val) {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
for _, r := range s {
|
||||||
return false
|
if !isAlpha(r) && !isDigit(r) && r != '_' && r != '-' {
|
||||||
}
|
return false
|
||||||
|
|
||||||
// isTable determines if a value renders as [Table] / [[Array of Tables]].
|
|
||||||
// Slices must be homogeneous: mixing table and scalar elements is an error
|
|
||||||
// (parser accepts such arrays; encoder cannot represent them — documented
|
|
||||||
// limitation). Nil pointer elements are allowed in table slices and skipped
|
|
||||||
// at emission; nil interface elements are an error.
|
|
||||||
func (e *encoder) isTable(v reflect.Value) (bool, error) {
|
|
||||||
if v.Kind() == reflect.Interface {
|
|
||||||
v = v.Elem()
|
|
||||||
}
|
|
||||||
if v.Kind() == reflect.Ptr {
|
|
||||||
v = v.Elem()
|
|
||||||
}
|
|
||||||
|
|
||||||
switch v.Kind() {
|
|
||||||
case reflect.Struct, reflect.Map:
|
|
||||||
return true, nil
|
|
||||||
case reflect.Slice, reflect.Array:
|
|
||||||
tables, scalars := 0, 0
|
|
||||||
for i := 0; i < v.Len(); i++ {
|
|
||||||
elem := v.Index(i)
|
|
||||||
if elem.Kind() == reflect.Interface {
|
|
||||||
if elem.IsNil() {
|
|
||||||
return false, fmt.Errorf("nil element at index %d", i)
|
|
||||||
}
|
|
||||||
elem = elem.Elem()
|
|
||||||
}
|
|
||||||
if elem.Kind() == reflect.Ptr {
|
|
||||||
if elem.IsNil() {
|
|
||||||
tables++ // nil pointers are only legal in [[table]] context (skipped)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
elem = elem.Elem()
|
|
||||||
}
|
|
||||||
switch elem.Kind() {
|
|
||||||
case reflect.Struct, reflect.Map:
|
|
||||||
tables++
|
|
||||||
default:
|
|
||||||
scalars++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if tables > 0 && scalars > 0 {
|
|
||||||
return false, fmt.Errorf("mixed table/scalar elements in array")
|
|
||||||
}
|
|
||||||
return tables > 0, nil
|
|
||||||
}
|
}
|
||||||
return false, nil
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEmptyValue(v reflect.Value) bool {
|
func isEmptyValue(v reflect.Value) bool {
|
||||||
switch v.Kind() {
|
switch v.Kind() {
|
||||||
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
|
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
|
||||||
return v.Len() == 0
|
return v.Len() == 0
|
||||||
case reflect.Bool:
|
default:
|
||||||
return !v.Bool()
|
return v.IsZero()
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
||||||
return v.Int() == 0
|
|
||||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
||||||
return v.Uint() == 0
|
|
||||||
case reflect.Float32, reflect.Float64:
|
|
||||||
return v.Float() == 0
|
|
||||||
case reflect.Interface, reflect.Ptr:
|
|
||||||
return v.IsNil()
|
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *encoder) writeKey(s string) error {
|
|
||||||
if isBareKey(s) {
|
|
||||||
_, err := e.w.WriteString(s)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
e.encodeString(s)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *encoder) encodeString(s string) {
|
|
||||||
e.w.WriteString("\"")
|
|
||||||
for _, r := range s {
|
|
||||||
switch r {
|
|
||||||
case '"':
|
|
||||||
e.w.WriteString(`\"`)
|
|
||||||
case '\\':
|
|
||||||
e.w.WriteString(`\\`)
|
|
||||||
case '\n':
|
|
||||||
e.w.WriteString(`\n`)
|
|
||||||
case '\r':
|
|
||||||
e.w.WriteString(`\r`)
|
|
||||||
case '\t':
|
|
||||||
e.w.WriteString(`\t`)
|
|
||||||
case '\b':
|
|
||||||
e.w.WriteString(`\b`)
|
|
||||||
case '\f':
|
|
||||||
e.w.WriteString(`\f`)
|
|
||||||
default:
|
|
||||||
if r < 0x20 || r == 0x7F {
|
|
||||||
e.w.WriteString(fmt.Sprintf(`\u%04X`, r))
|
|
||||||
} else {
|
|
||||||
e.w.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.w.WriteString("\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
// isBareKey determines if a string can be written as a bare key
|
|
||||||
// It follows the TOML spec (A-Za-z0-9_-) but also respects the provided Lexer's behavior
|
|
||||||
// If the Lexer would interpret the string as a Number or Boolean, it must be quoted because the Parser expects TokenIdent (or TokenString) for keys
|
|
||||||
func isBareKey(s string) bool {
|
|
||||||
if s == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Valid bare key characters: A-Za-z0-9_-
|
|
||||||
for _, r := range s {
|
|
||||||
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Avoid collision with Booleans
|
|
||||||
// Lexer emits TokenBool for these, Parser expects TokenIdent.
|
|
||||||
if s == "true" || s == "false" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Avoid collision with Numbers
|
|
||||||
// The provided Lexer triggers number parsing if the token starts with a digit,
|
|
||||||
// or a '-' followed by a digit.
|
|
||||||
// Since the Lexer never produces TokenIdent for these cases (it produces TokenInteger/Float),
|
|
||||||
// and the Parser rejects numeric tokens as keys, we must quote them.
|
|
||||||
c0 := s[0]
|
|
||||||
if c0 >= '0' && c0 <= '9' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if c0 == '-' && len(s) > 1 {
|
|
||||||
c1 := s[1]
|
|
||||||
if c1 >= '0' && c1 <= '9' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
+10
-9
@@ -2,8 +2,8 @@ package toml
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func FuzzParse(f *testing.F) {
|
func FuzzParse(f *testing.F) {
|
||||||
@@ -57,24 +57,25 @@ func FuzzRoundTrip(f *testing.F) {
|
|||||||
`m = { x = 1, y = { z = "q" } }`,
|
`m = { x = 1, y = { z = "q" } }`,
|
||||||
`u = "日本語 \u00E9"`,
|
`u = "日本語 \u00E9"`,
|
||||||
"big = 1e100\nsmall = 1e-100",
|
"big = 1e100\nsmall = 1e-100",
|
||||||
|
`"a.b" = { "c d" = 1 }`,
|
||||||
|
`mixed = [1, {value = "x"}, [{nested = true}]]`,
|
||||||
|
"[[outer]]\n[outer.\"inner.key\"]\nx = \"\\b\\f\"\n[[outer]]\nx = 2",
|
||||||
} {
|
} {
|
||||||
f.Add([]byte(s))
|
f.Add([]byte(s))
|
||||||
}
|
}
|
||||||
f.Fuzz(func(t *testing.T, data []byte) {
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
// Encoder normalizes invalid UTF-8 to U+FFFD (WriteRune); byte-exact
|
|
||||||
// round trip is only a property of valid input.
|
|
||||||
if !utf8.Valid(data) {
|
|
||||||
t.Skip()
|
|
||||||
}
|
|
||||||
m1, err := NewParser(data).Parse()
|
m1, err := NewParser(data).Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skip()
|
t.Skip()
|
||||||
}
|
}
|
||||||
out, err := Marshal(m1)
|
out, err := Marshal(m1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Parser accepts values the encoder cannot represent
|
// Separate dotted paths may form a tree deeper than the encoder's
|
||||||
// (mixed table/scalar arrays). Documented asymmetry.
|
// documented whole-value bound. No other encode failure is allowed.
|
||||||
t.Skip()
|
if strings.Contains(err.Error(), "nesting exceeds") {
|
||||||
|
t.Skip()
|
||||||
|
}
|
||||||
|
t.Fatalf("parsed value cannot be encoded: %v", err)
|
||||||
}
|
}
|
||||||
m2, err := NewParser(out).Parse()
|
m2, err := NewParser(out).Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
module github.com/lixenwraith/toml
|
module github.com/lixenwraith/toml
|
||||||
|
|
||||||
go 1.24.0
|
go 1.27.1
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package toml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckedNumericConversions(t *testing.T) {
|
||||||
|
type Count uint64
|
||||||
|
tests := []struct {
|
||||||
|
value any
|
||||||
|
target any
|
||||||
|
want any
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{uint(42), new(int64), int64(42), true},
|
||||||
|
{Count(math.MaxUint64), new(uint64), uint64(math.MaxUint64), true},
|
||||||
|
{uint64(math.MaxUint64), new(int64), nil, false},
|
||||||
|
{int64(-1), new(uint64), nil, false},
|
||||||
|
{float64(0x1p63), new(int64), nil, false},
|
||||||
|
{float64(-0x1p63), new(int64), int64(math.MinInt64), true},
|
||||||
|
{float64(0x1p64), new(uint64), nil, false},
|
||||||
|
{1.5, new(int), nil, false},
|
||||||
|
{math.NaN(), new(int), nil, false},
|
||||||
|
{math.Inf(1), new(uint64), nil, false},
|
||||||
|
{float32(12), new(uint8), uint8(12), true},
|
||||||
|
{uint64(1<<53) + 1, new(float64), nil, false},
|
||||||
|
{int64(1<<24) + 1, new(float32), nil, false},
|
||||||
|
{uint64(1 << 63), new(float64), float64(1 << 63), true},
|
||||||
|
{1e100, new(float32), nil, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
err := Decode(tt.value, tt.target)
|
||||||
|
if (err == nil) != tt.valid {
|
||||||
|
t.Errorf("%v (%T) -> %T: %v", tt.value, tt.value, tt.target, err)
|
||||||
|
}
|
||||||
|
if tt.valid && !reflect.DeepEqual(reflect.ValueOf(tt.target).Elem().Interface(), tt.want) {
|
||||||
|
t.Errorf("got %v, want %v", tt.target, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeTransactionAndNamedKeys(t *testing.T) {
|
||||||
|
type Key string
|
||||||
|
var m map[Key]int
|
||||||
|
if err := Decode(map[string]any{"x": 4}, &m); err != nil || m["x"] != 4 {
|
||||||
|
t.Fatalf("%v %#v", err, m)
|
||||||
|
}
|
||||||
|
type C struct {
|
||||||
|
First int `toml:",omitempty"`
|
||||||
|
Second int8
|
||||||
|
}
|
||||||
|
c := C{7, 8}
|
||||||
|
if err := Decode(map[string]any{"First": 11, "Second": 400}, &c); err == nil || c != (C{7, 8}) {
|
||||||
|
t.Fatalf("partial update: %+v %v", c, err)
|
||||||
|
}
|
||||||
|
if err := Decode(map[string]any{"First": 11}, &c); err != nil || c.First != 11 {
|
||||||
|
t.Fatalf("empty tag name: %+v %v", c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundTripSupportedValues(t *testing.T) {
|
||||||
|
input := map[string]any{
|
||||||
|
"dot.key": map[string]any{"quoted\"key": map[string]any{"x": "\b\f\x00\x7f界"}},
|
||||||
|
"items": []any{map[string]any{"x": int64(3)}, map[string]any{"x": int64(4)}},
|
||||||
|
"nested": []any{[]any{map[string]any{"x": true}}},
|
||||||
|
"mixed": []any{int64(1), map[string]any{"y": "text"}},
|
||||||
|
}
|
||||||
|
data, err := Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := NewParser(data).Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", data, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(normalize(input), normalize(got)) {
|
||||||
|
t.Fatalf("round trip: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalRejectsInvalidValues(t *testing.T) {
|
||||||
|
cycle := map[string]any{}
|
||||||
|
cycle["self"] = cycle
|
||||||
|
for _, value := range []any{
|
||||||
|
map[string]any{"u": uint64(math.MaxUint64)},
|
||||||
|
map[string]any{"s": string([]byte{0xff})},
|
||||||
|
map[string]any{"123": 1},
|
||||||
|
struct {
|
||||||
|
A int `toml:"x"`
|
||||||
|
B int `toml:"x"`
|
||||||
|
}{},
|
||||||
|
cycle,
|
||||||
|
} {
|
||||||
|
if _, err := Marshal(value); err == nil {
|
||||||
|
t.Errorf("invalid %T accepted", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBoundariesAndSyntax(t *testing.T) {
|
||||||
|
m, err := NewParser([]byte("min = -9223372036854775808\nmax = 9223372036854775807")).Parse()
|
||||||
|
if err != nil || m["min"] != int64(math.MinInt64) || m["max"] != int64(math.MaxInt64) {
|
||||||
|
t.Fatalf("%#v %v", m, err)
|
||||||
|
}
|
||||||
|
for _, s := range []string{"x=9223372036854775808", "x=1 y=2", "[x] y=2", "x=[1\n2]", "x={a=1\nb=2}", "x=\"\x00\"", "x=\"\xff\"", "[" + strings.Repeat("a.", 1100) + "b]"} {
|
||||||
|
if _, err := NewParser([]byte(s)).Parse(); err == nil {
|
||||||
|
t.Errorf("accepted invalid input %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzIntegerConversions(f *testing.F) {
|
||||||
|
f.Add(uint64(math.MaxUint64))
|
||||||
|
f.Add(uint64(1<<53) + 1)
|
||||||
|
f.Add(uint64(0))
|
||||||
|
f.Add(uint64(255))
|
||||||
|
f.Add(uint64(256))
|
||||||
|
f.Add(uint64(1 << 63))
|
||||||
|
f.Fuzz(func(t *testing.T, n uint64) {
|
||||||
|
var u uint64
|
||||||
|
if err := Decode(n, &u); err != nil || u != n {
|
||||||
|
t.Fatalf("uint64 changed: %d -> %d (%v)", n, u, err)
|
||||||
|
}
|
||||||
|
var i int64
|
||||||
|
err := Decode(n, &i)
|
||||||
|
if (err == nil) != (n <= math.MaxInt64) {
|
||||||
|
t.Fatalf("signed boundary: %d %v", n, err)
|
||||||
|
}
|
||||||
|
if err == nil && uint64(i) != n {
|
||||||
|
t.Fatal("signed conversion changed value")
|
||||||
|
}
|
||||||
|
var b uint8
|
||||||
|
err = Decode(n, &b)
|
||||||
|
if (err == nil) != (n <= math.MaxUint8) || err == nil && uint64(b) != n {
|
||||||
|
t.Fatalf("narrow conversion changed value: %d -> %d (%v)", n, b, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package toml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecodeFixedArrayRoundTrip(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
Value uint16 `toml:"value"`
|
||||||
|
}
|
||||||
|
type Config struct {
|
||||||
|
Numbers [3]int64 `toml:"numbers"`
|
||||||
|
Items [2]*Item `toml:"items"`
|
||||||
|
Matrix [2][2]int8 `toml:"matrix"`
|
||||||
|
Empty [0]string `toml:"empty"`
|
||||||
|
}
|
||||||
|
want := Config{
|
||||||
|
Numbers: [3]int64{math.MinInt64, 0, math.MaxInt64},
|
||||||
|
Items: [2]*Item{{1}, {math.MaxUint16}},
|
||||||
|
Matrix: [2][2]int8{{-128, 127}, {0, 1}},
|
||||||
|
}
|
||||||
|
data, err := Marshal(want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got Config
|
||||||
|
if err := Unmarshal(data, &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
for _, input := range []any{[]any{1}, []any{1, 2, 3}, []any{1, 256}} {
|
||||||
|
dst := [2]uint8{7, 8}
|
||||||
|
if err := Decode(input, &dst); err == nil || dst != [2]uint8{7, 8} {
|
||||||
|
t.Fatalf("invalid array changed target: %v (%v)", dst, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodePointerDefaultsAndRollback(t *testing.T) {
|
||||||
|
type Settings struct {
|
||||||
|
Port uint16 `toml:"port"`
|
||||||
|
Name string `toml:"name"`
|
||||||
|
}
|
||||||
|
original := &Settings{Port: 8080, Name: "default"}
|
||||||
|
dst := original
|
||||||
|
if err := Decode(map[string]any{"name": "updated"}, &dst); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dst.Port != 8080 || dst.Name != "updated" || dst == original || original.Name != "default" {
|
||||||
|
t.Fatalf("defaults or original pointer changed: dst=%+v original=%+v", dst, original)
|
||||||
|
}
|
||||||
|
type Config struct {
|
||||||
|
Settings *Settings
|
||||||
|
Map map[string]int
|
||||||
|
Slice []int
|
||||||
|
Limit uint8
|
||||||
|
}
|
||||||
|
cfg := Config{original, map[string]int{"old": 1}, []int{2}, 3}
|
||||||
|
err := Decode(map[string]any{
|
||||||
|
"Settings": map[string]any{"port": 9090},
|
||||||
|
"Map": map[string]any{"new": 4},
|
||||||
|
"Slice": []any{5},
|
||||||
|
"Limit": 256,
|
||||||
|
}, &cfg)
|
||||||
|
if err == nil || cfg.Settings != original || *original != (Settings{8080, "default"}) ||
|
||||||
|
!reflect.DeepEqual(cfg.Map, map[string]int{"old": 1}) || !reflect.DeepEqual(cfg.Slice, []int{2}) || cfg.Limit != 3 {
|
||||||
|
t.Fatalf("partial update: %+v (%v)", cfg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCyclicInputErrors(t *testing.T) {
|
||||||
|
cycle := map[string]any{}
|
||||||
|
cycle["self"] = cycle
|
||||||
|
var number uint64
|
||||||
|
if err := Decode(cycle, &number); err == nil {
|
||||||
|
t.Fatal("accepted cyclic map as unsigned number")
|
||||||
|
}
|
||||||
|
type Node struct {
|
||||||
|
Next *Node `toml:"self"`
|
||||||
|
}
|
||||||
|
var node Node
|
||||||
|
if err := Decode(cycle, &node); err == nil || node.Next != nil {
|
||||||
|
t.Fatalf("recursive decode must fail without publication: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNumericKeyRangeIndependence(t *testing.T) {
|
||||||
|
for _, key := range []string{"0", "-1", "+1", "9223372036854775808", "-9223372036854775809", strings.Repeat("9", 100)} {
|
||||||
|
if _, err := Marshal(map[string]any{key: 1}); err == nil {
|
||||||
|
t.Errorf("encoded numeric key %q", key)
|
||||||
|
}
|
||||||
|
if _, err := NewParser([]byte(strconv.Quote(key) + " = 1")).Parse(); err == nil {
|
||||||
|
t.Errorf("parsed numeric key %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeIntegerWidths(t *testing.T) {
|
||||||
|
for _, typ := range []reflect.Type{
|
||||||
|
reflect.TypeFor[int](), reflect.TypeFor[int8](), reflect.TypeFor[int16](), reflect.TypeFor[int32](), reflect.TypeFor[int64](),
|
||||||
|
reflect.TypeFor[uint](), reflect.TypeFor[uint8](), reflect.TypeFor[uint16](), reflect.TypeFor[uint32](), reflect.TypeFor[uint64](),
|
||||||
|
} {
|
||||||
|
t.Run(typ.String(), func(t *testing.T) {
|
||||||
|
bits := typ.Bits()
|
||||||
|
signed := typ.Kind() >= reflect.Int && typ.Kind() <= reflect.Int64
|
||||||
|
max := uint64(math.MaxUint64) >> (64 - bits)
|
||||||
|
if signed {
|
||||||
|
max >>= 1
|
||||||
|
}
|
||||||
|
dst := reflect.New(typ)
|
||||||
|
if err := Decode(max, dst.Interface()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if max < math.MaxUint64 {
|
||||||
|
before := dst.Elem().Interface()
|
||||||
|
if err := Decode(max+1, dst.Interface()); err == nil || dst.Elem().Interface() != before {
|
||||||
|
t.Fatalf("overflow was accepted or mutated destination: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if signed {
|
||||||
|
min := -int64(max) - 1
|
||||||
|
if err := Decode(min, dst.Interface()); err != nil || dst.Elem().Int() != min {
|
||||||
|
t.Fatalf("minimum changed: %v", err)
|
||||||
|
}
|
||||||
|
if min > math.MinInt64 {
|
||||||
|
if err := Decode(min-1, dst.Interface()); err == nil || dst.Elem().Int() != min {
|
||||||
|
t.Fatalf("underflow accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if err := Decode(int64(-1), dst.Interface()); err == nil {
|
||||||
|
t.Fatal("negative signed integer accepted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -329,7 +329,7 @@ func (l *Lexer) readString() Token {
|
|||||||
|
|
||||||
for l.pos < len(l.input) {
|
for l.pos < len(l.input) {
|
||||||
ch := l.peek()
|
ch := l.peek()
|
||||||
if ch == '\n' {
|
if ch < 0x20 && ch != '\t' || ch == 0x7f {
|
||||||
return l.newToken(TokenError, "unterminated string: newline in basic string")
|
return l.newToken(TokenError, "unterminated string: newline in basic string")
|
||||||
}
|
}
|
||||||
if ch == '"' && !escaped {
|
if ch == '"' && !escaped {
|
||||||
@@ -353,6 +353,10 @@ func (l *Lexer) readString() Token {
|
|||||||
result = append(result, '\t')
|
result = append(result, '\t')
|
||||||
case 'r':
|
case 'r':
|
||||||
result = append(result, '\r')
|
result = append(result, '\r')
|
||||||
|
case 'b':
|
||||||
|
result = append(result, '\b')
|
||||||
|
case 'f':
|
||||||
|
result = append(result, '\f')
|
||||||
case 'u', 'U':
|
case 'u', 'U':
|
||||||
n := 4
|
n := 4
|
||||||
if ch == 'U' {
|
if ch == 'U' {
|
||||||
@@ -427,4 +431,3 @@ func isOctalDigit(r rune) bool {
|
|||||||
func isBinaryDigit(r rune) bool {
|
func isBinaryDigit(r rune) bool {
|
||||||
return r == '0' || r == '1'
|
return r == '0' || r == '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
+5
-4
@@ -62,7 +62,8 @@ func TestDecode_DeepPointers(t *testing.T) {
|
|||||||
|
|
||||||
func TestDecode_LargeIntPrecision(t *testing.T) {
|
func TestDecode_LargeIntPrecision(t *testing.T) {
|
||||||
largeVal := int64(4611686018427387905)
|
largeVal := int64(4611686018427387905)
|
||||||
data := map[string]any{"id": int(largeVal)}
|
// Keep the source value intact on 32-bit platforms too.
|
||||||
|
data := map[string]any{"id": largeVal}
|
||||||
|
|
||||||
type T struct {
|
type T struct {
|
||||||
ID int64 `toml:"id"`
|
ID int64 `toml:"id"`
|
||||||
@@ -191,11 +192,11 @@ mid_val = 2
|
|||||||
}
|
}
|
||||||
|
|
||||||
a := res["a"].(map[string]any)
|
a := res["a"].(map[string]any)
|
||||||
if a["root_val"] != 1 {
|
if a["root_val"] != int64(1) {
|
||||||
t.Errorf("Missing root_val: %v", a["root_val"])
|
t.Errorf("Missing root_val: %v", a["root_val"])
|
||||||
}
|
}
|
||||||
b := a["b"].(map[string]any)
|
b := a["b"].(map[string]any)
|
||||||
if b["mid_val"] != 2 {
|
if b["mid_val"] != int64(2) {
|
||||||
t.Errorf("Missing mid_val: %v", b["mid_val"])
|
t.Errorf("Missing mid_val: %v", b["mid_val"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,7 +385,7 @@ i = 5
|
|||||||
|
|
||||||
a := res["a"].(map[string]any)
|
a := res["a"].(map[string]any)
|
||||||
b := a["b"].(map[string]any)
|
b := a["b"].(map[string]any)
|
||||||
if b["i"] != 5 {
|
if b["i"] != int64(5) {
|
||||||
t.Errorf("Value 'i' lost in table reentry. Got %v", b["i"])
|
t.Errorf("Value 'i' lost in table reentry. Got %v", b["i"])
|
||||||
}
|
}
|
||||||
if _, ok := b["c"].(map[string]any); !ok {
|
if _, ok := b["c"].(map[string]any); !ok {
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Enforce 64-bit platform; fails compilation on 32-bit targets where int(int64) in parseInteger would truncate
|
|
||||||
const _ uint = 1<<63 - 1
|
|
||||||
|
|
||||||
// Parser parses TOML tokens into a map[string]any
|
// Parser parses TOML tokens into a map[string]any
|
||||||
type Parser struct {
|
type Parser struct {
|
||||||
root map[string]any
|
root map[string]any
|
||||||
@@ -49,6 +47,9 @@ func (p *Parser) nextToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Parser) Parse() (map[string]any, error) {
|
func (p *Parser) Parse() (map[string]any, error) {
|
||||||
|
if !utf8.Valid(p.lexer.input) {
|
||||||
|
return nil, fmt.Errorf("invalid UTF-8")
|
||||||
|
}
|
||||||
for p.curToken.Type != TokenEOF {
|
for p.curToken.Type != TokenEOF {
|
||||||
if p.curToken.Type == TokenNewline {
|
if p.curToken.Type == TokenNewline {
|
||||||
p.nextToken()
|
p.nextToken()
|
||||||
@@ -58,6 +59,9 @@ func (p *Parser) Parse() (map[string]any, error) {
|
|||||||
if err := p.parseStatement(); err != nil {
|
if err := p.parseStatement(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if p.curToken.Type != TokenNewline && p.curToken.Type != TokenEOF {
|
||||||
|
return nil, fmt.Errorf("expected newline at line %d", p.curToken.Line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return p.root, nil
|
return p.root, nil
|
||||||
}
|
}
|
||||||
@@ -254,7 +258,7 @@ func (p *Parser) parseKeyParts() ([]string, error) {
|
|||||||
|
|
||||||
if p.curToken.Type == TokenString {
|
if p.curToken.Type == TokenString {
|
||||||
// Rule: Even quoted strings shouldn't be pure numbers per instruction
|
// Rule: Even quoted strings shouldn't be pure numbers per instruction
|
||||||
if _, err := strconv.Atoi(p.curToken.Literal); err == nil {
|
if numericKey(p.curToken.Literal) {
|
||||||
return nil, fmt.Errorf("numeric string keys are forbidden: %q", p.curToken.Literal)
|
return nil, fmt.Errorf("numeric string keys are forbidden: %q", p.curToken.Literal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,6 +267,9 @@ func (p *Parser) parseKeyParts() ([]string, error) {
|
|||||||
return nil, fmt.Errorf("expected key, got %s", p.curToken.String())
|
return nil, fmt.Errorf("expected key, got %s", p.curToken.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(keys) >= maxValueDepth {
|
||||||
|
return nil, fmt.Errorf("key nesting exceeds %d", maxValueDepth)
|
||||||
|
}
|
||||||
keys = append(keys, p.curToken.Literal)
|
keys = append(keys, p.curToken.Literal)
|
||||||
p.nextToken()
|
p.nextToken()
|
||||||
|
|
||||||
@@ -314,7 +321,7 @@ func (p *Parser) parseValue() (any, error) {
|
|||||||
return nil, fmt.Errorf("unexpected value token %s at line %d", p.curToken.String(), p.curToken.Line)
|
return nil, fmt.Errorf("unexpected value token %s at line %d", p.curToken.String(), p.curToken.Line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Parser) parseInteger(lit string) (int, error) {
|
func (p *Parser) parseInteger(lit string) (int64, error) {
|
||||||
// Handle optional leading sign
|
// Handle optional leading sign
|
||||||
negative := false
|
negative := false
|
||||||
numLit := lit
|
numLit := lit
|
||||||
@@ -329,26 +336,23 @@ func (p *Parser) parseInteger(lit string) (int, error) {
|
|||||||
if len(numLit) > 2 && numLit[0] == '0' {
|
if len(numLit) > 2 && numLit[0] == '0' {
|
||||||
switch numLit[1] {
|
switch numLit[1] {
|
||||||
case 'x', 'X':
|
case 'x', 'X':
|
||||||
val, err = strconv.ParseInt(numLit[2:], 16, 64)
|
val, err = strconv.ParseInt(signPrefix(negative)+numLit[2:], 16, 64)
|
||||||
case 'o', 'O':
|
case 'o', 'O':
|
||||||
val, err = strconv.ParseInt(numLit[2:], 8, 64)
|
val, err = strconv.ParseInt(signPrefix(negative)+numLit[2:], 8, 64)
|
||||||
case 'b', 'B':
|
case 'b', 'B':
|
||||||
val, err = strconv.ParseInt(numLit[2:], 2, 64)
|
val, err = strconv.ParseInt(signPrefix(negative)+numLit[2:], 2, 64)
|
||||||
default:
|
default:
|
||||||
val, err = strconv.ParseInt(lit, 10, 64)
|
val, err = strconv.ParseInt(lit, 10, 64)
|
||||||
return int(val), err
|
return val, err
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if negative {
|
return val, nil
|
||||||
val = -val
|
|
||||||
}
|
|
||||||
return int(val), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val, err = strconv.ParseInt(lit, 10, 64)
|
val, err = strconv.ParseInt(lit, 10, 64)
|
||||||
return int(val), err
|
return val, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Parser) parseArray() ([]any, error) {
|
func (p *Parser) parseArray() ([]any, error) {
|
||||||
@@ -367,15 +371,12 @@ func (p *Parser) parseArray() ([]any, error) {
|
|||||||
}
|
}
|
||||||
arr = append(arr, val)
|
arr = append(arr, val)
|
||||||
|
|
||||||
|
for p.curToken.Type == TokenNewline {
|
||||||
|
p.nextToken()
|
||||||
|
}
|
||||||
if p.curToken.Type == TokenComma {
|
if p.curToken.Type == TokenComma {
|
||||||
p.nextToken()
|
p.nextToken()
|
||||||
} else if p.curToken.Type != TokenRBracket {
|
} else if p.curToken.Type != TokenRBracket {
|
||||||
// Check for newlines between elements if missing comma? TOML usually requires comma.
|
|
||||||
// Relaxed parser: require comma unless followed immediately by bracket (trailing comma allowed)
|
|
||||||
if p.curToken.Type == TokenNewline {
|
|
||||||
p.nextToken()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("expected comma or closing bracket in array at line %d", p.curToken.Line)
|
return nil, fmt.Errorf("expected comma or closing bracket in array at line %d", p.curToken.Line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,13 +415,12 @@ func (p *Parser) parseInlineTable() (map[string]any, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for p.curToken.Type == TokenNewline {
|
||||||
|
p.nextToken()
|
||||||
|
}
|
||||||
if p.curToken.Type == TokenComma {
|
if p.curToken.Type == TokenComma {
|
||||||
p.nextToken()
|
p.nextToken()
|
||||||
} else if p.curToken.Type != TokenRBrace {
|
} else if p.curToken.Type != TokenRBrace {
|
||||||
if p.curToken.Type == TokenNewline {
|
|
||||||
p.nextToken()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("expected comma or closing brace in inline table at line %d", p.curToken.Line)
|
return nil, fmt.Errorf("expected comma or closing brace in inline table at line %d", p.curToken.Line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,3 +433,26 @@ func (p *Parser) parseInlineTable() (map[string]any, error) {
|
|||||||
p.frozen[reflect.ValueOf(m).Pointer()] = true
|
p.frozen[reflect.ValueOf(m).Pointer()] = true
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func signPrefix(negative bool) string {
|
||||||
|
if negative {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the package's decimal-key restriction independent of numeric range.
|
||||||
|
func numericKey(s string) bool {
|
||||||
|
if len(s) > 0 && (s[0] == '+' || s[0] == '-') {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if !isDigit(c) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+1
-1
@@ -81,7 +81,7 @@ active = false
|
|||||||
}
|
}
|
||||||
// Check int conversion in dynamic map (parser returns int/float, decode handles struct fields, but map keeps raw parser types)
|
// Check int conversion in dynamic map (parser returns int/float, decode handles struct fields, but map keeps raw parser types)
|
||||||
// Parser likely returns int for 55.
|
// Parser likely returns int for 55.
|
||||||
if id, ok := cfg.Owner["id"].(int); !ok || id != 55 {
|
if id, ok := cfg.Owner["id"].(int64); !ok || id != 55 {
|
||||||
// Fallback check if parser returned generic float for number
|
// Fallback check if parser returned generic float for number
|
||||||
if fId, okf := cfg.Owner["id"].(float64); !okf || fId != 55 {
|
if fId, okf := cfg.Owner["id"].(float64); !okf || fId != 55 {
|
||||||
t.Errorf("Owner.ID mismatch: got %T %v", cfg.Owner["id"], cfg.Owner["id"])
|
t.Errorf("Owner.ID mismatch: got %T %v", cfg.Owner["id"], cfg.Owner["id"])
|
||||||
|
|||||||
Reference in new issue
Block a user