From 6ef7933de8557b52db9251b291578cbd0f99c201 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Wed, 11 Nov 2026 22:49:56 +0000 Subject: [PATCH 1/2] Keep v1 behavior for struct fields read from keys and allowshadow slices MapTo leaves a struct field unchanged when only a key of its name exists, as v1 did, so programs that parse such a key themselves (frp's legacy bandwidth_limit) keep working. StrictMapTo still reports it. ReflectFrom writes an allowshadow slice of several elements as one value joined by the delimiter when the file has no AllowShadows, instead of failing. It reads back as all elements; v1 wrote only the first. --- docs/migrate-v1-to-v2.md | 1 + struct.go | 12 ++++++++++-- struct_test.go | 39 ++++++++++++++++++++++++++++++++------- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/docs/migrate-v1-to-v2.md b/docs/migrate-v1-to-v2.md index 9e689a1..98acace 100644 --- a/docs/migrate-v1-to-v2.md +++ b/docs/migrate-v1-to-v2.md @@ -67,6 +67,7 @@ In v1, `f.Section("server").Key("PORT")` added an empty `[server]` section and ` - Nil pointer fields are skipped. - Fields after an empty `nonunique` slice or a `StructReflector` field are written. - An `allowshadow` element containing the delimiter is an error, since it would read back as two elements. + - In a file loaded without `AllowShadows`, an `allowshadow` slice of several elements is written as one value joined by the delimiter, which reads back as all of them. v1 wrote only the first element. ### Writing diff --git a/struct.go b/struct.go index 7183438..25db8ff 100644 --- a/struct.go +++ b/struct.go @@ -341,7 +341,9 @@ func wrapStrictError(err error, isStrict bool) error { // decodeValue sets field from the value of key. A value that doesn't parse // leaves the field as it is, so it keeps its default, and is an error only -// when isStrict. An unsupported type is always an error. +// when isStrict. So does a key for a struct field, which maps to a section, +// as when the program parses that key itself. Any other unsupported type is +// always an error. func decodeValue(t reflect.Type, key *Key, field reflect.Value, delim string, allowShadow, isStrict bool) error { if t.Kind() == reflect.Slice && !isCustomType(t) { return decodeSlice(key, field, delim, allowShadow, isStrict) @@ -353,6 +355,9 @@ func decodeValue(t reflect.Type, key *Key, field reflect.Value, delim string, al vt = t.Elem() } if !isValueType(vt) { + if isStructType(vt) { + return wrapStrictError(fmt.Errorf("type %q maps to a section, not a key", t), isStrict) + } return fmt.Errorf("unsupported type %q", t) } @@ -615,8 +620,11 @@ func encodeSlice(key *Key, field reflect.Value, delim string, allowShadow bool) return fmt.Errorf("element %q contains the delimiter %q, which an allowshadow slice can't hold", v, delim) } } + // Without AllowShadows the elements share one value, which the + // reader splits at delim all the same. if len(vals) > 1 && !key.s.f.options.AllowShadows { - return errors.New("shadow key is not allowed") + key.SetValue(strings.Join(vals, delim)) + return nil } // Shadows deduplicated as the file's options say, on a key that // isn't boolean or auto-increment. diff --git a/struct_test.go b/struct_test.go index b7e00ff..59625b4 100644 --- a/struct_test.go +++ b/struct_test.go @@ -1041,13 +1041,24 @@ func TestMapTo_DurationPointerWithNumber(t *testing.T) { } func TestMapTo_StructFieldsFromKeys(t *testing.T) { + // A struct field maps to a section. A key of its name, which a program + // such as frp parses itself, leaves the field as it is, and the fields + // around it still map, as in v1. StrictMapTo reports it. type sub struct{ A int } - var c1 struct{ Sub sub } - assert.Error(t, MapTo(&c1, []byte("Sub = 2006-01-02T15:04:05Z\n"))) - var c2 struct{ Sub *sub } - assert.Error(t, MapTo(&c2, []byte("Sub = 2006-01-02T15:04:05Z\n"))) - var c3 struct{ Items []sub } - assert.Error(t, MapTo(&c3, []byte("Items = x\n"))) + type cfg struct { + Port int + Sub sub + Ptr *sub + } + data := []byte("Port = 22\nSub = 1MB\nPtr = 1MB\n") + c := cfg{Sub: sub{A: 7}} + require.NoError(t, MapTo(&c, data)) + assert.Equal(t, cfg{Port: 22, Sub: sub{A: 7}}, c) + assert.Error(t, StrictMapTo(&cfg{}, []byte("Sub = 1MB\n"))) + assert.Error(t, StrictMapTo(&cfg{}, []byte("Ptr = 1MB\n"))) + + var items struct{ Items []sub } + assert.Error(t, MapTo(&items, []byte("Items = x\n"))) } type testTag string @@ -1319,8 +1330,22 @@ func TestReflectFrom_AllowShadow(t *testing.T) { // The comment of a key goes before its last line, as the lexer reads it. assert.Equal(t, "path = a\n; search paths\npath = b\n", string(data)) - assert.Error(t, Empty().ReflectFrom(&cfg{Paths: []string{"a", "b"}}), "values must not be dropped silently") + // Without AllowShadows the elements share one value, which reads back + // as all of them with or without AllowShadows. f := Empty() + require.NoError(t, f.ReflectFrom(&cfg{Paths: []string{"a", "b", "c"}})) + var buf bytes.Buffer + _, err = f.Write(&buf) + require.NoError(t, err) + assert.Equal(t, "; search paths\npath = a,b,c\n", buf.String()) + for _, shadows := range []bool{false, true} { + var out cfg + require.NoError(t, MapTo(&out, buf.Bytes(), AllowShadows(shadows))) + assert.Equal(t, []string{"a", "b", "c"}, out.Paths, "AllowShadows(%v)", shadows) + } + assert.Error(t, Empty().ReflectFrom(&cfg{Paths: []string{"a", "b,c"}})) + + f = Empty() require.NoError(t, f.ReflectFrom(&cfg{Paths: []string{"a"}})) assert.Equal(t, "a", f.Section("").Key("path").String()) } From 884d01a5e1fac5c7b68511449a60c7d3b975e8eb Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Wed, 11 Nov 2026 23:31:02 +0000 Subject: [PATCH 2/2] Document ini:"-" for struct fields parsed from a key --- docs/migrate-v1-to-v2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/migrate-v1-to-v2.md b/docs/migrate-v1-to-v2.md index 98acace..f3a58a8 100644 --- a/docs/migrate-v1-to-v2.md +++ b/docs/migrate-v1-to-v2.md @@ -69,6 +69,8 @@ In v1, `f.Section("server").Key("PORT")` added an empty `[server]` section and ` - An `allowshadow` element containing the delimiter is an error, since it would read back as two elements. - In a file loaded without `AllowShadows`, an `allowshadow` slice of several elements is written as one value joined by the delimiter, which reads back as all of them. v1 wrote only the first element. +A struct field maps to a section. When a file has a key of the field's name but no such section, `MapTo` leaves the field unchanged, as v1 did, and `StrictMapTo` returns an error. To parse that key yourself, tag the field `ini:"-"` so both skip it, then read it with `Section.Key`. To have `MapTo` and `ReflectFrom` map the key, give the field's type both `MarshalINI` and `UnmarshalINI`. + ### Writing For ordinary files, `Write` output is byte-identical to v1's, apart from the parsing fixes above and a value with a backtick inside but not at its start: v1 wrapped it in `"""`, and v2 doesn't unless it also has a comment symbol. Where v1 wrote a file that loaded back differently, v2 writes one that loads back the same, or returns an error. Comments load back with the same text, though the spacing after a comment symbol can change. Output meant for other readers isn't checked: keys written with a `WriteDelimiter` the file wasn't loaded with, boolean keys without `AllowBooleanKeys`, and raw sections without `RawSections`. For example: