From 4def1b3e8b2965a822ba36612420be5644fc4331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Thu, 17 Sep 2026 23:11:10 +0200 Subject: [PATCH] fix(encode): reject a nil MarshalTOML result Assisted-by: GLM 5.3 --- CHANGELOG.md | 4 ++++ docs/API.md | 5 ++++- encode.go | 21 ++++++++++++++++++++- encode_test.go | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abf6c6e..f866942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A `MarshalTOML` result of `nil` with a nil error fails the marshal with + `MarshalTOML returned a nil value`. The field silently vanished before, and + inside a value array the nil result reached reflection as a zero value and + panicked. - Decoding into a struct that embeds a pointer to itself terminates. The schema walk recursed through the embedded type forever, so such a `Unmarshal` call hung the process; the walk now tracks the struct types on diff --git a/docs/API.md b/docs/API.md index 58d5e2a..717fce3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -309,7 +309,10 @@ The returned value is encoded as if it had been passed in place of the receiver, so it may be a scalar, a slice, an array of tables, or another struct or map, including the `Marshaler` result of another type; the encoder recurses. An error returned from `MarshalTOML` fails the marshal wrapped with -the key path, for example `interpres: server.port: bad timestamp`. +the key path, for example `interpres: server.port: bad timestamp`. A result +of `nil` with a nil error fails the same way with +`MarshalTOML returned a nil value`: nil has no TOML representation, so +dropping the field silently is not an option. ```go type Port int diff --git a/encode.go b/encode.go index d85a397..dcd4a17 100644 --- a/encode.go +++ b/encode.go @@ -300,6 +300,12 @@ func buildMapDoc(v reflect.Value, doc *tomlDoc, ctx string) error { // --- reflection walk: field dispatch ------------------------------------- +// errNilMarshalTOML reports a Marshaler whose method returned a nil value +// with no error. nil has no TOML representation, so dropping the field +// silently or panicking on the invalid reflect.Value would both hide the +// contract violation. +var errNilMarshalTOML = errors.New("MarshalTOML returned a nil value") + func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error { if v.CanInterface() { if m, ok := v.Interface().(Marshaler); ok { @@ -307,6 +313,9 @@ func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error { if err != nil { return &EncodeError{Path: joinKey(ctx, name), Err: err} } + if mv == nil { + return &EncodeError{Path: joinKey(ctx, name), Err: errNilMarshalTOML} + } v = reflect.ValueOf(mv) } } @@ -448,6 +457,9 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error if err != nil { return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err} } + if mv == nil { + return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errNilMarshalTOML} + } ev = reflect.ValueOf(mv) ev = followPtr(ev) } @@ -474,7 +486,14 @@ func normaliseValue(v reflect.Value) (any, error) { } if v.CanInterface() { if m, ok := v.Interface().(Marshaler); ok { - return m.MarshalTOML() + mv, err := m.MarshalTOML() + if err != nil { + return nil, err + } + if mv == nil { + return nil, errNilMarshalTOML + } + return mv, nil } } // The datetime structs are TOML scalars; the emitter renders each of them. diff --git a/encode_test.go b/encode_test.go index c5e74b4..ed9932c 100644 --- a/encode_test.go +++ b/encode_test.go @@ -383,6 +383,39 @@ func TestMarshalerErrorPropagates(t *testing.T) { } } +// nilMarshalerFunc is a Marshaler whose method returns nil with no error. +type nilMarshalerFunc struct{} + +func (nilMarshalerFunc) MarshalTOML() (any, error) { return nil, nil } + +func TestMarshalRejectsNilMarshalerResult(t *testing.T) { + // nil has no TOML representation, so a MarshalTOML result of nil is an + // error, not a silently dropped field. + _, err := Marshal(struct { + F nilMarshalerFunc `toml:"f"` + }{}) + if err == nil { + t.Fatal("expected an error for a nil MarshalTOML result") + } + ee, ok := errors.AsType[*EncodeError](err) + if !ok { + t.Fatalf("expected an *EncodeError, got %T: %v", err, err) + } + if ee.Path != "f" { + t.Fatalf("Path = %q, want %q", ee.Path, "f") + } + + // Inside a value array the nil result used to reach reflection as a zero + // Value and panic. + _, err = Marshal(map[string]any{"arr": []any{1, nilMarshalerFunc{}}}) + if err == nil { + t.Fatal("expected an error for a nil MarshalTOML result in an array") + } + if !strings.Contains(err.Error(), "MarshalTOML returned a nil value") { + t.Errorf("err = %v, want the nil-result message", err) + } +} + // Two fields that resolve to one TOML key must marshal as one key, resolved // the way the decoder resolves it, or the output would carry a duplicate key // and never re-parse.