From bef1d3fbd9d891cd9bfda6dc4bf3f8c7b9d02786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Tue, 22 Sep 2026 01:03:27 +0200 Subject: [PATCH] test: add FuzzMarshal, golden messages, synctest cancellation and cross smoke Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 5 + datetime.go | 6 + decode_test.go | 120 ++++++++++++++++++ fuzz_test.go | 68 ++++++++++ interpres_test.go | 25 ++++ justfile | 11 +- testdata/fuzz/FuzzParse/949d7c0ab58a94f9 | 2 + testdata/fuzz/FuzzParse/seed11_escapes | 2 + .../fuzz/FuzzParse/seed11_inline_multiline | 2 + testdata/fuzz/FuzzParse/seed11_times | 2 + 10 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 testdata/fuzz/FuzzParse/949d7c0ab58a94f9 create mode 100644 testdata/fuzz/FuzzParse/seed11_escapes create mode 100644 testdata/fuzz/FuzzParse/seed11_inline_multiline create mode 100644 testdata/fuzz/FuzzParse/seed11_times diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c0ba7..5474309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -194,6 +194,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An offset date-time written with the `+00:00` offset kept the anonymous + location `time.Parse` invents for it, so a round trip through the tree and + `Marshal`, which writes a zero offset as `Z`, changed the value's + reflection-visible location. The zero offset normalises to `time.UTC` at + parse, and the tree is stable across the round trip. - Decoding into a defined type whose underlying kind is string or bool, such as `type Name string`, panicked instead of storing the value, because a value of the predeclared type is not assignable to a defined type and the diff --git a/datetime.go b/datetime.go index 54b6e4f..7d54649 100644 --- a/datetime.go +++ b/datetime.go @@ -258,6 +258,12 @@ func parseDateTime(tok string) (any, bool) { if err != nil { return nil, false } + // A zero offset carries its own anonymous location from time.Parse, + // while the written form is "Z" either way; normalising to UTC keeps + // the tree identical across the round trip. + if _, off := t.Zone(); off == 0 { + t = t.In(time.UTC) + } return OffsetDateTime{t}, true case dateTimeLocal: layout := localClockLayout diff --git a/decode_test.go b/decode_test.go index 57bb5a6..29c925e 100644 --- a/decode_test.go +++ b/decode_test.go @@ -13,6 +13,7 @@ import ( "strings" "sync/atomic" "testing" + "testing/synctest" "time" ) @@ -1574,3 +1575,122 @@ func TestCancelInsideValue(t *testing.T) { t.Errorf("err = %v, want context.Canceled", err) } } + +func TestCancellationSynctest(t *testing.T) { + // The bubble makes the cost of the immediate-cancellation path visible in + // virtual microseconds, and synctest.Wait holds the test to leaving no + // goroutine behind. + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + start := time.Now() + var tree map[string]any + err := NewDecoder().DecodeContext(ctx, []byte("a = 1\n"), &tree) + if !errors.Is(err, context.Canceled) { + t.Errorf("err = %v, want context.Canceled", err) + } + if d := time.Since(start); d != 0 { + t.Errorf("the parse consumed %v of virtual time, want none", d) + } + synctest.Wait() + }) +} + +func TestErrorMessagesGolden(t *testing.T) { + // The exact texts the library promises, pinned against unintended edits. + type Config struct { + Weight uint8 `toml:"weight"` + } + tests := []struct { + name string + read func() error + want string + }{ + { + name: "missing equals", + read: func() error { _, err := ParseMap([]byte("a 1\n")); return err }, + want: "interpres: line 1: expected '=' after key", + }, + { + name: "duplicate key", + read: func() error { _, err := ParseMap([]byte("a = 1\na = 2\n")); return err }, + want: `interpres: line 2: duplicate key "a"`, + }, + { + name: "unterminated string", + read: func() error { _, err := ParseMap([]byte("a = \"open\n")); return err }, + want: "interpres: line 1: unterminated string", + }, + { + name: "leading zero", + read: func() error { _, err := ParseMap([]byte("a = 01\n")); return err }, + want: "interpres: line 1: leading zeros are not allowed in numbers", + }, + { + name: "bad escape", + read: func() error { _, err := ParseMap([]byte(`a = "\q"` + "\n")); return err }, + want: `interpres: line 1: invalid escape sequence \q`, + }, + { + name: "nesting limit", + read: func() error { + var b strings.Builder + b.WriteString("a = ") + for range 11 { + b.WriteString("[") + } + for range 11 { + b.WriteString("]") + } + b.WriteString("\n") + var tree map[string]any + err := NewDecoder().MaxDepth(10).Decode([]byte(b.String()), &tree) + return err + }, + want: "interpres: line 1: nesting exceeds the limit of 10", + }, + { + name: "decode overflow", + read: func() error { + var cfg Config + return Unmarshal([]byte("weight = 300\n"), &cfg) + }, + want: "interpres: weight: integer 300 overflows uint8", + }, + { + name: "unknown field", + read: func() error { + var cfg struct { + Known int `toml:"known"` + } + return NewDecoder().DisallowUnknownFields().Decode([]byte("mystery = 1\n"), &cfg) + }, + want: `interpres: unknown field "mystery" for struct { Known int "toml:\"known\"" }`, + }, + { + name: "decode target", + read: func() error { return Unmarshal([]byte("a = 1\n"), Config{}) }, + want: "interpres: decode target must be a non-nil pointer", + }, + { + name: "encode nil pointer", + read: func() error { + var p *Config + _, err := Marshal(p) + return err + }, + want: "interpres: cannot marshal nil pointer", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.read() + if err == nil { + t.Fatalf("no error, want %q", tt.want) + } + if err.Error() != tt.want { + t.Errorf("message = %q, want %q", err.Error(), tt.want) + } + }) + } +} diff --git a/fuzz_test.go b/fuzz_test.go index 3e01afc..1cb4958 100644 --- a/fuzz_test.go +++ b/fuzz_test.go @@ -121,3 +121,71 @@ func tomlEqual(a, b any) bool { return reflect.DeepEqual(a, b) } } + +// FuzzMarshal drives the encoder with generated Go values and holds it to +// the same round-trip invariant FuzzParse holds the parser to: a value built +// only of encodable kinds must marshal, the document must re-parse, and the +// tree must equal the value it came from. +func FuzzMarshal(f *testing.F) { + seeds := [][]byte{ + {}, + {0, 0, 1, 2}, + {1, 1, 2, 3, 2, 2, 3, 4}, + {0, 3, 1, 9, 3, 3, 2, 8, 1, 0, 1, 7}, + } + for _, s := range seeds { + f.Add(s) + } + f.Fuzz(func(t *testing.T, data []byte) { + v := fuzzValue(data) + out, err := Marshal(v) + if err != nil { + t.Fatalf("marshal of an encodable value failed: %v\nvalue: %#v", err, v) + } + tree, err := ParseMap(out) + if err != nil { + t.Fatalf("re-parse of the emitted document failed: %v\ndoc:\n%s", err, out) + } + if !tomlEqual(v, tree) { + t.Fatalf("round-trip changed the value\nvalue: %#v\ndoc:\n%s\ntree: %#v", v, out, tree) + } + }) +} + +// fuzzKeys is the fixed key pool the generated values draw from, so keys are +// always valid bare keys and repeat often. +var fuzzKeys = []string{"alpha", "beta", "gamma", "delta"} + +// fuzzValue builds a map[string]any of encodable kinds from data: integers, +// positive floats, short strings, nested tables and scalar arrays. The bytes +// decide the shape deterministically. +func fuzzValue(data []byte) map[string]any { + root := map[string]any{} + cur := root + depth := 0 + for i := 0; i+3 < len(data); i += 4 { + key := fuzzKeys[int(data[i])%len(fuzzKeys)] + switch data[i+1] % 5 { + case 0: + cur[key] = int64(data[i+2])<<8 | int64(data[i+3]) + case 1: + cur[key] = float64(int(data[i+2])%1000)/8.0 + 0.125 + case 2: + cur[key] = string(rune('a' + int(data[i+2])%26)) + case 3: + cur[key] = []any{ + int64(data[i+2]), + float64(int(data[i+3])%100)/4.0 + 0.25, + string(rune('a' + int(data[i+3])%26)), + } + case 4: + if depth < 6 { + next := map[string]any{} + cur[key] = next + cur = next + depth++ + } + } + } + return root +} diff --git a/interpres_test.go b/interpres_test.go index 1af0f98..c771e41 100644 --- a/interpres_test.go +++ b/interpres_test.go @@ -796,3 +796,28 @@ func TestParseAsAndNewSchema(t *testing.T) { } NewSchema[map[string]any]() // must not panic } + +func TestZeroOffsetRoundTrip(t *testing.T) { + // A document may write a zero offset as +00:00; the tree must hold the + // same value after a round trip, because the written form is "Z" either + // way. + src := []byte("a = 1979-05-27T07:32:00+00:00\n") + tree, err := ParseMap(src) + if err != nil { + t.Fatal(err) + } + out, err := Marshal(tree) + if err != nil { + t.Fatal(err) + } + re, err := ParseMap(out) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(tree, re) { + t.Errorf("round trip changed the tree: %#v vs %#v", tree, re) + } + if got := tree["a"].(OffsetDateTime).String(); got != "1979-05-27T07:32Z" { + t.Errorf("a = %q, want 1979-05-27T07:32Z", got) + } +} diff --git a/justfile b/justfile index 677ca1a..931a442 100644 --- a/justfile +++ b/justfile @@ -32,7 +32,7 @@ test: close($c); die qq{no total line in coverage.out\n} unless defined $total; printf qq{Total coverage: %s%%\n}, $total; - exit($total < 80 ? 1 : 0); + exit($total < 82 ? 1 : 0); # The same suite under the race detector. The expensive one. race: @@ -100,6 +100,15 @@ toml-test: build coverage-html: test go tool cover -html=coverage.out -o coverage.html +# Cross-compile smoke: the library and the command build for the foreign architectures; not a gate, it is a nightly convenience and runs std-lib only. +cross: + GOARCH=arm64 go build ./... + GOARCH=loong64 go build ./... + GOARCH=riscv64 go build ./... + GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null {{package}} + GOARCH=loong64 CGO_ENABLED=0 go build -o /dev/null {{package}} + GOARCH=riscv64 CGO_ENABLED=0 go build -o /dev/null {{package}} + # Runs the example program under examples/basic; not standard because `run` runs the adapter, and an example is documentation, not the product. example: go run ./examples/basic diff --git a/testdata/fuzz/FuzzParse/949d7c0ab58a94f9 b/testdata/fuzz/FuzzParse/949d7c0ab58a94f9 new file mode 100644 index 0000000..12cf65c --- /dev/null +++ b/testdata/fuzz/FuzzParse/949d7c0ab58a94f9 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("0=00:00\n1=0000-01-01 00:00:00.0+00:00#000000000000") diff --git a/testdata/fuzz/FuzzParse/seed11_escapes b/testdata/fuzz/FuzzParse/seed11_escapes new file mode 100644 index 0000000..9bb7b0d --- /dev/null +++ b/testdata/fuzz/FuzzParse/seed11_escapes @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("e = \"\\\\e[0m\\\\x41\\\\x7f\\\\x00\"\n") diff --git a/testdata/fuzz/FuzzParse/seed11_inline_multiline b/testdata/fuzz/FuzzParse/seed11_inline_multiline new file mode 100644 index 0000000..d0979f2 --- /dev/null +++ b/testdata/fuzz/FuzzParse/seed11_inline_multiline @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("m = {\n\ttitle = \"one\",\n\tnums = [1, 2,],\n\tinner = { deep = true }, # trailing\n}\n") diff --git a/testdata/fuzz/FuzzParse/seed11_times b/testdata/fuzz/FuzzParse/seed11_times new file mode 100644 index 0000000..bd09877 --- /dev/null +++ b/testdata/fuzz/FuzzParse/seed11_times @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("t = 13:37\nbig = 1979-05-27 07:32:00.5+01:00\nshort = 1979-05-27 07:32\n")