detached 6bd019c

test(html): guard JSON encoder against update field drift

Add a reflection-filled test comparing appendUpdateJSON with json.Marshal
and pinning field counts. Invalid color kinds now return the same
json.MarshalerError text as before. Fix dead frame.Set and U+FFFD comment;
compare invalid UTF-8 bytes directly.
commit
6bd019c91964a4e1cc8bb6d257320dcc39867a20
author
brice · · 2026-10-02 09:24 UTC
parent
b6d6950
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +108 -19
M html/encode.go +9 -3
@@ -1,7 +1,9 @@
11 package html
22
33 import (
4+ "encoding/json"
45 "fmt"
6+ "reflect"
57 "strconv"
68 "unicode/utf8"
79 )
@@ -155,7 +157,11 @@ func appendColorJSON(dst []byte, color Color) ([]byte, error) {
155157 dst = strconv.AppendUint(dst, uint64(color.RGB.B), 10)
156158 return append(dst, "}}"...), nil
157159 default:
158- return nil, fmt.Errorf("html: invalid color kind %d", color.Kind)
160+ // Same error value encoding/json produced via Color.MarshalJSON.
161+ return nil, &json.MarshalerError{
162+ Type: reflect.TypeFor[*Color](),
163+ Err: fmt.Errorf("html: invalid color kind %d", color.Kind),
164+ }
159165 }
160166 }
161167
@@ -163,8 +169,8 @@ const hexDigits = "0123456789abcdef"
163169
164170 // appendJSONString appends s as a JSON string using encoding/json's default
165171 // escaping: quotes, backslashes, control characters, <, >, & and U+2028/2029
166-// are escaped, and invalid UTF-8 becomes a literal U+FFFD (as encoding/json
167-// does on this toolchain). Cell text is always valid UTF-8 here.
172+// are escaped, and each invalid UTF-8 byte becomes U+FFFD written as the
173+// literal character, byte-identical to encoding/json on this toolchain.
168174 func appendJSONString(dst []byte, s string) []byte {
169175 dst = append(dst, '"')
170176 start := 0
@@ -1,7 +1,9 @@
1 package html1 package html
2 2
3 import (3 import (
4+ "encoding/json"
4 "fmt"5 "fmt"
6+ "reflect"
5 "strconv"7 "strconv"
6 "unicode/utf8"8 "unicode/utf8"
7 )9 )
@@ -155,7 +157,11 @@ func appendColorJSON(dst []byte, color Color) ([]byte, error) {
155 dst = strconv.AppendUint(dst, uint64(color.RGB.B), 10)157 dst = strconv.AppendUint(dst, uint64(color.RGB.B), 10)
156 return append(dst, "}}"...), nil158 return append(dst, "}}"...), nil
157 default:159 default:
158- return nil, fmt.Errorf("html: invalid color kind %d", color.Kind)160+ // Same error value encoding/json produced via Color.MarshalJSON.
161+ return nil, &json.MarshalerError{
162+ Type: reflect.TypeFor[*Color](),
163+ Err: fmt.Errorf("html: invalid color kind %d", color.Kind),
164+ }
159 }165 }
160 }166 }
161 167
@@ -163,8 +169,8 @@ const hexDigits = "0123456789abcdef"
163 169
164 // appendJSONString appends s as a JSON string using encoding/json's default170 // appendJSONString appends s as a JSON string using encoding/json's default
165 // escaping: quotes, backslashes, control characters, <, >, & and U+2028/2029171 // escaping: quotes, backslashes, control characters, <, >, & and U+2028/2029
166-// are escaped, and invalid UTF-8 becomes a literal U+FFFD (as encoding/json172+// are escaped, and each invalid UTF-8 byte becomes U+FFFD written as the
167-// does on this toolchain). Cell text is always valid UTF-8 here.173+// literal character, byte-identical to encoding/json on this toolchain.
168 func appendJSONString(dst []byte, s string) []byte {174 func appendJSONString(dst []byte, s string) []byte {
169 dst = append(dst, '"')175 dst = append(dst, '"')
170 start := 0176 start := 0
M html/encode_test.go +99 -16
@@ -3,6 +3,7 @@ package html
33 import (
44 "encoding/json"
55 "math/rand"
6+ "reflect"
67 "testing"
78
89 "github.com/bnema/vev-vt/core"
@@ -24,17 +25,13 @@ func TestAppendJSONStringMatchesEncodingJSON(t *testing.T) {
2425 }
2526 }
2627
27-// Invalid UTF-8 never reaches the encoder (cells are validated), and the
28-// escaped form of U+FFFD differs between encoding/json implementations, so
29-// compare decoded values only.
30-func TestAppendJSONStringInvalidUTF8DecodesLikeEncodingJSON(t *testing.T) {
31- for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80"} {
28+// Invalid UTF-8 never reaches the encoder (cells are validated), but the
29+// escaping must still match encoding/json byte for byte.
30+func TestAppendJSONStringInvalidUTF8MatchesEncodingJSON(t *testing.T) {
31+ for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80", "\xe2\x80", "ok\xf0\x9f"} {
3232 want, err := json.Marshal(text)
3333 require.NoError(t, err)
34- var wantDecoded, gotDecoded string
35- require.NoError(t, json.Unmarshal(want, &wantDecoded))
36- require.NoError(t, json.Unmarshal(appendJSONString(nil, text), &gotDecoded))
37- require.Equal(t, wantDecoded, gotDecoded)
34+ require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text)
3835 }
3936 }
4037
@@ -103,9 +100,9 @@ func TestAppendUpdateJSONMatchesEncodingJSON(t *testing.T) {
103100 func TestAppendUpdateJSONRejectsInvalidColorKind(t *testing.T) {
104101 update := Update{Styles: []Style{{Foreground: Color{Kind: 9}}}}
105102 _, err := appendUpdateJSON(nil, update)
106- require.ErrorContains(t, err, "invalid color kind")
107- _, err = json.Marshal(update)
108- require.Error(t, err)
103+ _, want := json.Marshal(update)
104+ require.Error(t, want)
105+ require.EqualError(t, err, want.Error())
109106 }
110107
111108 func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) {
@@ -117,10 +114,10 @@ func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) {
117114 frame.Set(0, 0, core.Cell{Rune: '<', Style: style})
118115 frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
119116 frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
120- frame.Set(4, 1, core.Cell{Rune: '"', Style: style})
121- frame.Set(5, 1, core.Cell{Rune: '😀', Style: core.DefaultStyle()})
122- // width-2 rune at the last column is invalid; keep it narrow.
123- frame.Set(5, 1, core.Cell{Rune: '&', Style: core.DefaultStyle()})
117+ frame.Set(3, 0, core.Cell{Rune: '😀', Style: core.DefaultStyle()})
118+ frame.Set(4, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
119+ frame.Set(5, 0, core.Cell{Rune: '&', Style: core.DefaultStyle()})
120+ frame.Set(0, 1, core.Cell{Rune: '"', Style: style})
124121
125122 renderer, err := New(Options{})
126123 require.NoError(t, err)
@@ -129,4 +126,90 @@ func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) {
129126 want, err := json.Marshal(prepared.tx.update)
130127 require.NoError(t, err)
131128 require.Equal(t, string(want), string(prepared.JSON()))
129+ require.Contains(t, string(prepared.JSON()), "😀")
130+}
131+
132+// fillAll sets every field reachable from v to a distinctive non-zero value
133+// (negative for signed integers) so a field added to the update types without
134+// a matching encoder change shows up as a JSON mismatch.
135+func fillAll(t *testing.T, v reflect.Value, colorKind ColorKind, counter *int) {
136+ t.Helper()
137+ *counter++
138+ n := *counter
139+ if v.Type() == reflect.TypeOf(Color{}) {
140+ v.FieldByName("Kind").SetUint(uint64(colorKind))
141+ v.FieldByName("Index").SetUint(uint64(100 + n%100))
142+ fillAll(t, v.FieldByName("RGB"), colorKind, counter)
143+ return
144+ }
145+ switch v.Kind() {
146+ case reflect.Bool:
147+ v.SetBool(true)
148+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
149+ v.SetInt(-int64(n) - 1)
150+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
151+ v.SetUint(uint64(1 + n%120))
152+ case reflect.String:
153+ v.SetString("t<&>\"\\界\u2028" + string(rune('a'+n%26)))
154+ case reflect.Slice:
155+ v.Set(reflect.MakeSlice(v.Type(), 2, 2))
156+ for i := range 2 {
157+ fillAll(t, v.Index(i), colorKind, counter)
158+ }
159+ case reflect.Struct:
160+ for i := range v.NumField() {
161+ require.True(t, v.Field(i).CanSet(), "%s.%s must be settable", v.Type(), v.Type().Field(i).Name)
162+ fillAll(t, v.Field(i), colorKind, counter)
163+ }
164+ default:
165+ t.Fatalf("fillAll: unsupported kind %s in %s", v.Kind(), v.Type())
166+ }
167+}
168+
169+func requireNoZeroFields(t *testing.T, v reflect.Value) {
170+ t.Helper()
171+ switch v.Kind() {
172+ case reflect.Struct:
173+ for i := range v.NumField() {
174+ requireNoZeroFields(t, v.Field(i))
175+ }
176+ case reflect.Slice:
177+ require.NotZero(t, v.Len())
178+ for i := range v.Len() {
179+ requireNoZeroFields(t, v.Index(i))
180+ }
181+ default:
182+ require.False(t, v.IsZero(), "zero %s left in filled value", v.Type())
183+ }
184+}
185+
186+// TestEncoderCoversEveryUpdateField fails when a field is added to (or removed
187+// from) the update types without updating appendUpdateJSON and this test.
188+func TestEncoderCoversEveryUpdateField(t *testing.T) {
189+ for typ, fields := range map[reflect.Type]int{
190+ reflect.TypeOf(Update{}): 7,
191+ reflect.TypeOf(RowUpdate{}): 2,
192+ reflect.TypeOf(CellUpdate{}): 4,
193+ reflect.TypeOf(Style{}): 11,
194+ reflect.TypeOf(Cursor{}): 5,
195+ reflect.TypeOf(RGB{}): 3,
196+ reflect.TypeOf(Color{}): 3,
197+ } {
198+ require.Equal(t, fields, typ.NumField(), "%s field count changed: update appendUpdateJSON", typ)
199+ }
200+
201+ for _, kind := range []ColorKind{ColorDefault, ColorIndexed, ColorRGB} {
202+ var update Update
203+ counter := 0
204+ fillAll(t, reflect.ValueOf(&update).Elem(), kind, &counter)
205+ update.Cursor.Style = 6 // keep within the documented DECSCUSR range
206+ if kind != ColorDefault {
207+ requireNoZeroFields(t, reflect.ValueOf(update))
208+ }
209+ want, err := json.Marshal(update)
210+ require.NoError(t, err)
211+ got, err := appendUpdateJSON(nil, update)
212+ require.NoError(t, err)
213+ require.Equal(t, string(want), string(got), "color kind %d", kind)
214+ }
132215 }
@@ -3,6 +3,7 @@ package html
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5 "math/rand"5 "math/rand"
6+ "reflect"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/vev-vt/core"9 "github.com/bnema/vev-vt/core"
@@ -24,17 +25,13 @@ func TestAppendJSONStringMatchesEncodingJSON(t *testing.T) {
24 }25 }
25 }26 }
26 27
27-// Invalid UTF-8 never reaches the encoder (cells are validated), and the28+// Invalid UTF-8 never reaches the encoder (cells are validated), but the
28-// escaped form of U+FFFD differs between encoding/json implementations, so29+// escaping must still match encoding/json byte for byte.
29-// compare decoded values only.30+func TestAppendJSONStringInvalidUTF8MatchesEncodingJSON(t *testing.T) {
30-func TestAppendJSONStringInvalidUTF8DecodesLikeEncodingJSON(t *testing.T) {31+ for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80", "\xe2\x80", "ok\xf0\x9f"} {
31- for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80"} {
32 want, err := json.Marshal(text)32 want, err := json.Marshal(text)
33 require.NoError(t, err)33 require.NoError(t, err)
34- var wantDecoded, gotDecoded string34+ require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text)
35- require.NoError(t, json.Unmarshal(want, &wantDecoded))
36- require.NoError(t, json.Unmarshal(appendJSONString(nil, text), &gotDecoded))
37- require.Equal(t, wantDecoded, gotDecoded)
38 }35 }
39 }36 }
40 37
@@ -103,9 +100,9 @@ func TestAppendUpdateJSONMatchesEncodingJSON(t *testing.T) {
103 func TestAppendUpdateJSONRejectsInvalidColorKind(t *testing.T) {100 func TestAppendUpdateJSONRejectsInvalidColorKind(t *testing.T) {
104 update := Update{Styles: []Style{{Foreground: Color{Kind: 9}}}}101 update := Update{Styles: []Style{{Foreground: Color{Kind: 9}}}}
105 _, err := appendUpdateJSON(nil, update)102 _, err := appendUpdateJSON(nil, update)
106- require.ErrorContains(t, err, "invalid color kind")103+ _, want := json.Marshal(update)
107- _, err = json.Marshal(update)104+ require.Error(t, want)
108- require.Error(t, err)105+ require.EqualError(t, err, want.Error())
109 }106 }
110 107
111 func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) {108 func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) {
@@ -117,10 +114,10 @@ func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) {
117 frame.Set(0, 0, core.Cell{Rune: '<', Style: style})114 frame.Set(0, 0, core.Cell{Rune: '<', Style: style})
118 frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})115 frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
119 frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})116 frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
120- frame.Set(4, 1, core.Cell{Rune: '"', Style: style})117+ frame.Set(3, 0, core.Cell{Rune: '😀', Style: core.DefaultStyle()})
121- frame.Set(5, 1, core.Cell{Rune: '😀', Style: core.DefaultStyle()})118+ frame.Set(4, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
122- // width-2 rune at the last column is invalid; keep it narrow.119+ frame.Set(5, 0, core.Cell{Rune: '&', Style: core.DefaultStyle()})
123- frame.Set(5, 1, core.Cell{Rune: '&', Style: core.DefaultStyle()})120+ frame.Set(0, 1, core.Cell{Rune: '"', Style: style})
124 121
125 renderer, err := New(Options{})122 renderer, err := New(Options{})
126 require.NoError(t, err)123 require.NoError(t, err)
@@ -129,4 +126,90 @@ func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) {
129 want, err := json.Marshal(prepared.tx.update)126 want, err := json.Marshal(prepared.tx.update)
130 require.NoError(t, err)127 require.NoError(t, err)
131 require.Equal(t, string(want), string(prepared.JSON()))128 require.Equal(t, string(want), string(prepared.JSON()))
129+ require.Contains(t, string(prepared.JSON()), "😀")
130+}
131+
132+// fillAll sets every field reachable from v to a distinctive non-zero value
133+// (negative for signed integers) so a field added to the update types without
134+// a matching encoder change shows up as a JSON mismatch.
135+func fillAll(t *testing.T, v reflect.Value, colorKind ColorKind, counter *int) {
136+ t.Helper()
137+ *counter++
138+ n := *counter
139+ if v.Type() == reflect.TypeOf(Color{}) {
140+ v.FieldByName("Kind").SetUint(uint64(colorKind))
141+ v.FieldByName("Index").SetUint(uint64(100 + n%100))
142+ fillAll(t, v.FieldByName("RGB"), colorKind, counter)
143+ return
144+ }
145+ switch v.Kind() {
146+ case reflect.Bool:
147+ v.SetBool(true)
148+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
149+ v.SetInt(-int64(n) - 1)
150+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
151+ v.SetUint(uint64(1 + n%120))
152+ case reflect.String:
153+ v.SetString("t<&>\"\\界\u2028" + string(rune('a'+n%26)))
154+ case reflect.Slice:
155+ v.Set(reflect.MakeSlice(v.Type(), 2, 2))
156+ for i := range 2 {
157+ fillAll(t, v.Index(i), colorKind, counter)
158+ }
159+ case reflect.Struct:
160+ for i := range v.NumField() {
161+ require.True(t, v.Field(i).CanSet(), "%s.%s must be settable", v.Type(), v.Type().Field(i).Name)
162+ fillAll(t, v.Field(i), colorKind, counter)
163+ }
164+ default:
165+ t.Fatalf("fillAll: unsupported kind %s in %s", v.Kind(), v.Type())
166+ }
167+}
168+
169+func requireNoZeroFields(t *testing.T, v reflect.Value) {
170+ t.Helper()
171+ switch v.Kind() {
172+ case reflect.Struct:
173+ for i := range v.NumField() {
174+ requireNoZeroFields(t, v.Field(i))
175+ }
176+ case reflect.Slice:
177+ require.NotZero(t, v.Len())
178+ for i := range v.Len() {
179+ requireNoZeroFields(t, v.Index(i))
180+ }
181+ default:
182+ require.False(t, v.IsZero(), "zero %s left in filled value", v.Type())
183+ }
184+}
185+
186+// TestEncoderCoversEveryUpdateField fails when a field is added to (or removed
187+// from) the update types without updating appendUpdateJSON and this test.
188+func TestEncoderCoversEveryUpdateField(t *testing.T) {
189+ for typ, fields := range map[reflect.Type]int{
190+ reflect.TypeOf(Update{}): 7,
191+ reflect.TypeOf(RowUpdate{}): 2,
192+ reflect.TypeOf(CellUpdate{}): 4,
193+ reflect.TypeOf(Style{}): 11,
194+ reflect.TypeOf(Cursor{}): 5,
195+ reflect.TypeOf(RGB{}): 3,
196+ reflect.TypeOf(Color{}): 3,
197+ } {
198+ require.Equal(t, fields, typ.NumField(), "%s field count changed: update appendUpdateJSON", typ)
199+ }
200+
201+ for _, kind := range []ColorKind{ColorDefault, ColorIndexed, ColorRGB} {
202+ var update Update
203+ counter := 0
204+ fillAll(t, reflect.ValueOf(&update).Elem(), kind, &counter)
205+ update.Cursor.Style = 6 // keep within the documented DECSCUSR range
206+ if kind != ColorDefault {
207+ requireNoZeroFields(t, reflect.ValueOf(update))
208+ }
209+ want, err := json.Marshal(update)
210+ require.NoError(t, err)
211+ got, err := appendUpdateJSON(nil, update)
212+ require.NoError(t, err)
213+ require.Equal(t, string(want), string(got), "color kind %d", kind)
214+ }
132 }215 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close