detached 14c4667

test: fuzz ParseControl and the dbusmenu layout decoder, malformed bodies

FuzzParseControl checks that what is accepted respects every limit. FuzzDecodeLayout and a truncation test feed hand-built wire bodies (a raw bus that zerobus's encoder cannot produce) to the decoder; the unexpected-signature branch is covered with a fake application.
commit
14c4667128241d3360c00fbbfa72bc1aa4a23820
author
brice · · 2026-10-09 06:45 UTC
parent
35d84dc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +367 -0
M internal/module/control_test.go +60 -0
@@ -6,6 +6,8 @@ import (
66 "log/slog"
77 "strings"
88 "testing"
9+ "unicode"
10+ "unicode/utf8"
911 )
1012
1113 // newInteractive is newTest for a module that may send control lines.
@@ -205,3 +207,61 @@ func TestOversizeControlWarnsOncePerRun(t *testing.T) {
205207 t.Fatalf("a new run must warn again:\n%s", logs.String())
206208 }
207209 }
210+
211+func FuzzParseControl(f *testing.F) {
212+ for _, s := range []string{
213+ `{"type":"close"}`,
214+ `{"type":"tooltip","col":2,"width":1,"title":"Steam","body":"a\nb"}`,
215+ `{"type":"menu","col":0,"width":2,"click":9,"items":[{"id":1,"label":"Open"},{"id":2,"kind":"separator"},{"id":3,"label":"Mute","kind":"check","checked":true},{"id":4,"label":"S","items":[{"id":5,"label":"A"}]},{"id":6,"enabled":false}]}`,
216+ nested(MaxMenuDepth), nested(MaxMenuDepth + 1),
217+ `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31m\u202e\ud800"}]}`,
218+ `{"type":"tooltip","col":-1,"width":99999999,"title":"x"}`,
219+ `{"type":`, `[]`, `null`, ``, `{"type":"menu","items":null}`,
220+ } {
221+ f.Add([]byte(s))
222+ }
223+ f.Fuzz(func(t *testing.T, b []byte) {
224+ c, err := ParseControl(b)
225+ if err != nil {
226+ return
227+ }
228+ // What is accepted respects every limit and carries no control rune.
229+ if c.Col < 0 || c.Col > maxColumn || c.Width < 0 || c.Width > maxColumn {
230+ t.Fatalf("column %d width %d", c.Col, c.Width)
231+ }
232+ bad := func(s string, max int) {
233+ if len(s) > max || !utf8.ValidString(s) || strings.ContainsFunc(s, func(r rune) bool { return unicode.IsControl(r) && r != '\n' }) {
234+ t.Fatalf("text %q", s)
235+ }
236+ }
237+ bad(c.Title, MaxLabel)
238+ bad(c.Body, MaxText)
239+ count := 0
240+ var walk func(items []MenuItem, depth int)
241+ walk = func(items []MenuItem, depth int) {
242+ if len(items) > 0 && depth > MaxMenuDepth {
243+ t.Fatalf("depth %d", depth)
244+ }
245+ for _, it := range items {
246+ count++
247+ bad(it.Label, MaxLabel)
248+ if strings.ContainsRune(it.Label, '\n') || it.ID < 0 {
249+ t.Fatalf("item %+v", it)
250+ }
251+ switch it.Kind {
252+ case KindNormal, KindSeparator, KindCheck, KindRadio:
253+ default:
254+ t.Fatalf("kind %q", it.Kind)
255+ }
256+ walk(it.Items, depth+1)
257+ }
258+ }
259+ walk(c.Items, 1)
260+ if count > MaxMenuItems {
261+ t.Fatalf("%d items", count)
262+ }
263+ if c.Type == ControlMenu && len(c.Items) == 0 {
264+ t.Fatal("an empty menu was accepted")
265+ }
266+ })
267+}
@@ -6,6 +6,8 @@ import (
6 "log/slog"6 "log/slog"
7 "strings"7 "strings"
8 "testing"8 "testing"
9+ "unicode"
10+ "unicode/utf8"
9 )11 )
10 12
11 // newInteractive is newTest for a module that may send control lines.13 // newInteractive is newTest for a module that may send control lines.
@@ -205,3 +207,61 @@ func TestOversizeControlWarnsOncePerRun(t *testing.T) {
205 t.Fatalf("a new run must warn again:\n%s", logs.String())207 t.Fatalf("a new run must warn again:\n%s", logs.String())
206 }208 }
207 }209 }
210+
211+func FuzzParseControl(f *testing.F) {
212+ for _, s := range []string{
213+ `{"type":"close"}`,
214+ `{"type":"tooltip","col":2,"width":1,"title":"Steam","body":"a\nb"}`,
215+ `{"type":"menu","col":0,"width":2,"click":9,"items":[{"id":1,"label":"Open"},{"id":2,"kind":"separator"},{"id":3,"label":"Mute","kind":"check","checked":true},{"id":4,"label":"S","items":[{"id":5,"label":"A"}]},{"id":6,"enabled":false}]}`,
216+ nested(MaxMenuDepth), nested(MaxMenuDepth + 1),
217+ `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31m\u202e\ud800"}]}`,
218+ `{"type":"tooltip","col":-1,"width":99999999,"title":"x"}`,
219+ `{"type":`, `[]`, `null`, ``, `{"type":"menu","items":null}`,
220+ } {
221+ f.Add([]byte(s))
222+ }
223+ f.Fuzz(func(t *testing.T, b []byte) {
224+ c, err := ParseControl(b)
225+ if err != nil {
226+ return
227+ }
228+ // What is accepted respects every limit and carries no control rune.
229+ if c.Col < 0 || c.Col > maxColumn || c.Width < 0 || c.Width > maxColumn {
230+ t.Fatalf("column %d width %d", c.Col, c.Width)
231+ }
232+ bad := func(s string, max int) {
233+ if len(s) > max || !utf8.ValidString(s) || strings.ContainsFunc(s, func(r rune) bool { return unicode.IsControl(r) && r != '\n' }) {
234+ t.Fatalf("text %q", s)
235+ }
236+ }
237+ bad(c.Title, MaxLabel)
238+ bad(c.Body, MaxText)
239+ count := 0
240+ var walk func(items []MenuItem, depth int)
241+ walk = func(items []MenuItem, depth int) {
242+ if len(items) > 0 && depth > MaxMenuDepth {
243+ t.Fatalf("depth %d", depth)
244+ }
245+ for _, it := range items {
246+ count++
247+ bad(it.Label, MaxLabel)
248+ if strings.ContainsRune(it.Label, '\n') || it.ID < 0 {
249+ t.Fatalf("item %+v", it)
250+ }
251+ switch it.Kind {
252+ case KindNormal, KindSeparator, KindCheck, KindRadio:
253+ default:
254+ t.Fatalf("kind %q", it.Kind)
255+ }
256+ walk(it.Items, depth+1)
257+ }
258+ }
259+ walk(c.Items, 1)
260+ if count > MaxMenuItems {
261+ t.Fatalf("%d items", count)
262+ }
263+ if c.Type == ControlMenu && len(c.Items) == 0 {
264+ t.Fatal("an empty menu was accepted")
265+ }
266+ })
267+}
M internal/tray/dbusmenu_test.go +116 -0
@@ -1,7 +1,9 @@
11 package tray
22
33 import (
4+ "encoding/binary"
45 "fmt"
6+ "slices"
57 "strings"
68 "testing"
79 "time"
@@ -76,6 +78,10 @@ func (f *fakeItem) menuCall(m *zerobus.Message) bool {
7678 f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no menu")
7779 break
7880 }
81+ if f.badSig {
82+ f.c.NewReply(m, "s").Str("not a layout")
83+ break
84+ }
7985 e := f.c.NewReply(m, "u"+layoutSig)
8086 e.Uint32(1)
8187 f.menu.encode(e)
@@ -391,3 +397,113 @@ func noControlLineFor(t *testing.T, out *lines, typ string, d time.Duration) {
391397 }
392398 }
393399 }
400+
401+// A reply that is not a layout makes no menu, and the application hears that
402+// the menu it was told to open is closed.
403+func TestUnexpectedLayoutSignature(t *testing.T) {
404+ steam, _, in, out := setup(t)
405+ menuReady(t, steam, in, out, sampleLayout())
406+ in.send(t, "menu-closed 9")
407+ expectMenuCall(t, steam, "Event(0,closed)")
408+ steam.run(t, func() { steam.badSig = true })
409+ in.send(t, "click right 0 30")
410+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout", "Event(0,closed)"} {
411+ expectMenuCall(t, steam, want)
412+ }
413+ noControlLineFor(t, out, "menu", 300*time.Millisecond)
414+ in.send(t, "menu-activate 30 1")
415+ expectNoMenuCall(t, steam)
416+}
417+
418+// decodeRaw sends body as the reply to one call on c and decodes it.
419+func decodeRaw(t testing.TB, c *zerobus.Conn, next chan<- reply, sig string, body []byte) ([]menuItem, []int32, error) {
420+ t.Helper()
421+ next <- reply{sig, body}
422+ c.NewCall("x.y", "/MenuBar", menuIface, "GetLayout", "")
423+ m, err := c.Call()
424+ if err != nil {
425+ t.Fatal(err)
426+ }
427+ return decodeLayout(m.Body())
428+}
429+
430+// Bodies that are cut short or inconsistent must make an error, never a panic
431+// or an unbounded read.
432+func TestDecodeLayoutTruncatedAndMalformed(t *testing.T) {
433+ addr, next := rawBus(t)
434+ c := rawDial(t, addr)
435+ good := layoutBody(sampleLayout())
436+ if items, _, err := decodeRaw(t, c, next, "u"+layoutSig, good); err != nil || len(items) != 6 {
437+ t.Fatalf("the hand-built body does not decode like the encoder's: %d items, %v", len(items), err)
438+ }
439+ // Every strict prefix of a valid body.
440+ for n := 0; n < len(good); n++ {
441+ if _, _, err := decodeRaw(t, c, next, "u"+layoutSig, good[:n]); err == nil {
442+ t.Fatalf("a body cut at %d of %d bytes decoded without error", n, len(good))
443+ }
444+ }
445+ // A child array that claims more bytes than the message has.
446+ bad := slices.Clone(good)
447+ // The children array of the root sits right after the root's properties
448+ // (empty): find it by decoding position: revision(4) + pad(4) + id(4) + props len(4) + pad + kids len.
449+ binary.LittleEndian.PutUint32(bad[16:], 1<<20)
450+ if _, _, err := decodeRaw(t, c, next, "u"+layoutSig, bad); err == nil {
451+ t.Fatal("an oversized child array decoded without error")
452+ }
453+ // An item with a property of the wrong type: ignored, not an error.
454+ odd := &tnode{id: 0, kids: []tnode{{id: 1, props: []tprop{{"label", int32(5)}, {"enabled", "yes"}, {"visible", int32(0)}}}}}
455+ items, _, err := decodeRaw(t, c, next, "u"+layoutSig, layoutBody(odd))
456+ if err != nil || len(items) != 1 || items[0].Label != "" || !items[0].Enabled {
457+ t.Fatalf("odd properties: %+v, %v", items, err)
458+ }
459+ // Garbage of every kind does not panic.
460+ for _, junk := range [][]byte{nil, {0}, make([]byte, 7), make([]byte, 64), slices.Repeat([]byte{0xff}, 100)} {
461+ decodeRaw(t, c, next, "u"+layoutSig, junk)
462+ }
463+}
464+
465+func FuzzDecodeLayout(f *testing.F) {
466+ addr, next := rawBus(f)
467+ c := rawDial(f, addr)
468+ f.Add(layoutBody(sampleLayout()))
469+ f.Add(layoutBody(&tnode{id: 0}))
470+ f.Add(layoutBody(&tnode{id: 0, kids: []tnode{{id: 1, props: []tprop{{"label", "x"}, {"type", "separator"}}}}}))
471+ f.Add(layoutBody(sampleLayout())[:30])
472+ f.Add([]byte{})
473+ f.Fuzz(func(t *testing.T, body []byte) {
474+ if len(body) > 1<<16 {
475+ return
476+ }
477+ items, ids, err := decodeRaw(t, c, next, "u"+layoutSig, body)
478+ if err != nil {
479+ return
480+ }
481+ if n := countItems(items); n > maxMenuItems || len(ids) != n {
482+ t.Fatalf("%d items, %d ids", n, len(ids))
483+ }
484+ var walk func(l []menuItem, depth int)
485+ walk = func(l []menuItem, depth int) {
486+ if len(l) > 0 && depth > maxMenuDepth {
487+ t.Fatalf("depth %d", depth)
488+ }
489+ for _, it := range l {
490+ if len(it.Label) > maxMenuLabel || it.ID < 0 {
491+ t.Fatalf("item %+v", it)
492+ }
493+ walk(it.Items, depth+1)
494+ }
495+ }
496+ walk(items, 1)
497+ // Whatever decodes must be acceptable to the bar (an empty menu is
498+ // not sent at all).
499+ if len(items) == 0 {
500+ return
501+ }
502+ line, _, err := encodeMenu(menuLine{Type: "menu", Width: 1, Click: 1, Items: items})
503+ if err == nil {
504+ if _, err = module.ParseControl(line); err != nil {
505+ t.Fatalf("the bar refuses a decoded menu: %v", err)
506+ }
507+ }
508+ })
509+}
@@ -1,7 +1,9 @@
1 package tray1 package tray
2 2
3 import (3 import (
4+ "encoding/binary"
4 "fmt"5 "fmt"
6+ "slices"
5 "strings"7 "strings"
6 "testing"8 "testing"
7 "time"9 "time"
@@ -76,6 +78,10 @@ func (f *fakeItem) menuCall(m *zerobus.Message) bool {
76 f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no menu")78 f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no menu")
77 break79 break
78 }80 }
81+ if f.badSig {
82+ f.c.NewReply(m, "s").Str("not a layout")
83+ break
84+ }
79 e := f.c.NewReply(m, "u"+layoutSig)85 e := f.c.NewReply(m, "u"+layoutSig)
80 e.Uint32(1)86 e.Uint32(1)
81 f.menu.encode(e)87 f.menu.encode(e)
@@ -391,3 +397,113 @@ func noControlLineFor(t *testing.T, out *lines, typ string, d time.Duration) {
391 }397 }
392 }398 }
393 }399 }
400+
401+// A reply that is not a layout makes no menu, and the application hears that
402+// the menu it was told to open is closed.
403+func TestUnexpectedLayoutSignature(t *testing.T) {
404+ steam, _, in, out := setup(t)
405+ menuReady(t, steam, in, out, sampleLayout())
406+ in.send(t, "menu-closed 9")
407+ expectMenuCall(t, steam, "Event(0,closed)")
408+ steam.run(t, func() { steam.badSig = true })
409+ in.send(t, "click right 0 30")
410+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout", "Event(0,closed)"} {
411+ expectMenuCall(t, steam, want)
412+ }
413+ noControlLineFor(t, out, "menu", 300*time.Millisecond)
414+ in.send(t, "menu-activate 30 1")
415+ expectNoMenuCall(t, steam)
416+}
417+
418+// decodeRaw sends body as the reply to one call on c and decodes it.
419+func decodeRaw(t testing.TB, c *zerobus.Conn, next chan<- reply, sig string, body []byte) ([]menuItem, []int32, error) {
420+ t.Helper()
421+ next <- reply{sig, body}
422+ c.NewCall("x.y", "/MenuBar", menuIface, "GetLayout", "")
423+ m, err := c.Call()
424+ if err != nil {
425+ t.Fatal(err)
426+ }
427+ return decodeLayout(m.Body())
428+}
429+
430+// Bodies that are cut short or inconsistent must make an error, never a panic
431+// or an unbounded read.
432+func TestDecodeLayoutTruncatedAndMalformed(t *testing.T) {
433+ addr, next := rawBus(t)
434+ c := rawDial(t, addr)
435+ good := layoutBody(sampleLayout())
436+ if items, _, err := decodeRaw(t, c, next, "u"+layoutSig, good); err != nil || len(items) != 6 {
437+ t.Fatalf("the hand-built body does not decode like the encoder's: %d items, %v", len(items), err)
438+ }
439+ // Every strict prefix of a valid body.
440+ for n := 0; n < len(good); n++ {
441+ if _, _, err := decodeRaw(t, c, next, "u"+layoutSig, good[:n]); err == nil {
442+ t.Fatalf("a body cut at %d of %d bytes decoded without error", n, len(good))
443+ }
444+ }
445+ // A child array that claims more bytes than the message has.
446+ bad := slices.Clone(good)
447+ // The children array of the root sits right after the root's properties
448+ // (empty): find it by decoding position: revision(4) + pad(4) + id(4) + props len(4) + pad + kids len.
449+ binary.LittleEndian.PutUint32(bad[16:], 1<<20)
450+ if _, _, err := decodeRaw(t, c, next, "u"+layoutSig, bad); err == nil {
451+ t.Fatal("an oversized child array decoded without error")
452+ }
453+ // An item with a property of the wrong type: ignored, not an error.
454+ odd := &tnode{id: 0, kids: []tnode{{id: 1, props: []tprop{{"label", int32(5)}, {"enabled", "yes"}, {"visible", int32(0)}}}}}
455+ items, _, err := decodeRaw(t, c, next, "u"+layoutSig, layoutBody(odd))
456+ if err != nil || len(items) != 1 || items[0].Label != "" || !items[0].Enabled {
457+ t.Fatalf("odd properties: %+v, %v", items, err)
458+ }
459+ // Garbage of every kind does not panic.
460+ for _, junk := range [][]byte{nil, {0}, make([]byte, 7), make([]byte, 64), slices.Repeat([]byte{0xff}, 100)} {
461+ decodeRaw(t, c, next, "u"+layoutSig, junk)
462+ }
463+}
464+
465+func FuzzDecodeLayout(f *testing.F) {
466+ addr, next := rawBus(f)
467+ c := rawDial(f, addr)
468+ f.Add(layoutBody(sampleLayout()))
469+ f.Add(layoutBody(&tnode{id: 0}))
470+ f.Add(layoutBody(&tnode{id: 0, kids: []tnode{{id: 1, props: []tprop{{"label", "x"}, {"type", "separator"}}}}}))
471+ f.Add(layoutBody(sampleLayout())[:30])
472+ f.Add([]byte{})
473+ f.Fuzz(func(t *testing.T, body []byte) {
474+ if len(body) > 1<<16 {
475+ return
476+ }
477+ items, ids, err := decodeRaw(t, c, next, "u"+layoutSig, body)
478+ if err != nil {
479+ return
480+ }
481+ if n := countItems(items); n > maxMenuItems || len(ids) != n {
482+ t.Fatalf("%d items, %d ids", n, len(ids))
483+ }
484+ var walk func(l []menuItem, depth int)
485+ walk = func(l []menuItem, depth int) {
486+ if len(l) > 0 && depth > maxMenuDepth {
487+ t.Fatalf("depth %d", depth)
488+ }
489+ for _, it := range l {
490+ if len(it.Label) > maxMenuLabel || it.ID < 0 {
491+ t.Fatalf("item %+v", it)
492+ }
493+ walk(it.Items, depth+1)
494+ }
495+ }
496+ walk(items, 1)
497+ // Whatever decodes must be acceptable to the bar (an empty menu is
498+ // not sent at all).
499+ if len(items) == 0 {
500+ return
501+ }
502+ line, _, err := encodeMenu(menuLine{Type: "menu", Width: 1, Click: 1, Items: items})
503+ if err == nil {
504+ if _, err = module.ParseControl(line); err != nil {
505+ t.Fatalf("the bar refuses a decoded menu: %v", err)
506+ }
507+ }
508+ })
509+}
A internal/tray/rawbus_test.go +190 -0
@@ -0,0 +1,190 @@
1+package tray
2+
3+import (
4+ "bufio"
5+ "encoding/binary"
6+ "io"
7+ "net"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/bnema/zerobus"
12+)
13+
14+// wb builds D-Bus wire data by hand (little endian), so tests can send bodies
15+// that zerobus's own encoder refuses to produce: truncated or inconsistent.
16+type wb struct{ b []byte }
17+
18+func (w *wb) pad(n int) {
19+ for len(w.b)%n != 0 {
20+ w.b = append(w.b, 0)
21+ }
22+}
23+
24+func (w *wb) u32(v uint32) { w.pad(4); w.b = binary.LittleEndian.AppendUint32(w.b, v) }
25+func (w *wb) i32(v int32) { w.u32(uint32(v)) }
26+
27+func (w *wb) str(s string) {
28+ w.u32(uint32(len(s)))
29+ w.b = append(append(w.b, s...), 0)
30+}
31+
32+func (w *wb) sig(s string) { w.b = append(append(w.b, byte(len(s))), append([]byte(s), 0)...) }
33+
34+// lenPrefixed writes an array whose data starts aligned to align and is filled by fn.
35+func (w *wb) lenPrefixed(align int, fn func()) {
36+ w.u32(0)
37+ at := len(w.b) - 4
38+ w.pad(align)
39+ start := len(w.b)
40+ fn()
41+ binary.LittleEndian.PutUint32(w.b[at:], uint32(len(w.b)-start))
42+}
43+
44+// layoutBody is the body of a GetLayout reply for root: the revision, then the tree.
45+func layoutBody(root *tnode) []byte {
46+ var w wb
47+ w.u32(1)
48+ w.node(root)
49+ return w.b
50+}
51+
52+func (w *wb) node(n *tnode) {
53+ w.pad(8)
54+ w.i32(n.id)
55+ w.lenPrefixed(8, func() {
56+ for _, p := range n.props {
57+ w.pad(8)
58+ w.str(p.k)
59+ switch v := p.v.(type) {
60+ case string:
61+ w.sig("s")
62+ w.str(v)
63+ case bool:
64+ w.sig("b")
65+ if v {
66+ w.u32(1)
67+ } else {
68+ w.u32(0)
69+ }
70+ case int32:
71+ w.sig("i")
72+ w.i32(v)
73+ }
74+ }
75+ })
76+ w.lenPrefixed(1, func() {
77+ if n.junk {
78+ w.sig("s")
79+ w.str("not a node")
80+ }
81+ for i := range n.kids {
82+ w.sig(layoutSig)
83+ w.node(&n.kids[i])
84+ }
85+ })
86+}
87+
88+// frame is a method return of the given serials, with signature sig and body.
89+func frame(serial, replySerial uint32, sig string, body []byte) []byte {
90+ // Header field 5 (REPLY_SERIAL, u) and 8 (SIGNATURE, g).
91+ var fields wb
92+ fields.pad(8)
93+ fields.b = append(fields.b, 5)
94+ fields.sig("u")
95+ fields.u32(replySerial)
96+ if sig != "" {
97+ fields.pad(8)
98+ fields.b = append(fields.b, 8)
99+ fields.sig("g")
100+ fields.sig(sig)
101+ }
102+ h := []byte{'l', 2, 0, 1}
103+ h = binary.LittleEndian.AppendUint32(h, uint32(len(body)))
104+ h = binary.LittleEndian.AppendUint32(h, serial)
105+ h = binary.LittleEndian.AppendUint32(h, uint32(len(fields.b)))
106+ h = append(h, fields.b...)
107+ for len(h)%8 != 0 {
108+ h = append(h, 0)
109+ }
110+ return append(h, body...)
111+}
112+
113+// reply is what the raw bus answers to the next method call.
114+type reply struct {
115+ sig string
116+ body []byte
117+}
118+
119+// rawBus is a bus with one client that answers every method call after Hello
120+// with the next queued reply. It returns the address to dial.
121+func rawBus(t testing.TB) (addr string, next chan<- reply) {
122+ t.Helper()
123+ sock := filepath.Join(t.TempDir(), "bus")
124+ l, err := net.Listen("unix", sock)
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ t.Cleanup(func() { _ = l.Close() })
129+ ch := make(chan reply, 16)
130+ go func() {
131+ c, err := l.Accept()
132+ if err != nil {
133+ return
134+ }
135+ defer c.Close()
136+ r := bufio.NewReader(c)
137+ if _, err = r.ReadString('\n'); err != nil { // \0AUTH EXTERNAL ...
138+ return
139+ }
140+ if _, err = io.WriteString(c, "OK 0123456789abcdef\r\n"); err != nil {
141+ return
142+ }
143+ if _, err = r.ReadString('\n'); err != nil { // BEGIN
144+ return
145+ }
146+ var hello wb
147+ hello.str(":1.7")
148+ serial := uint32(1)
149+ first := true
150+ for {
151+ var hdr [16]byte
152+ if _, err = io.ReadFull(r, hdr[:]); err != nil {
153+ return
154+ }
155+ fields := binary.LittleEndian.Uint32(hdr[12:])
156+ rest := (16+int(fields)+7)/8*8 - 16 + int(binary.LittleEndian.Uint32(hdr[4:]))
157+ if _, err = io.CopyN(io.Discard, r, int64(rest)); err != nil {
158+ return
159+ }
160+ callSerial := binary.LittleEndian.Uint32(hdr[8:])
161+ var out []byte
162+ if first {
163+ first = false
164+ out = frame(serial, callSerial, "s", hello.b)
165+ } else {
166+ rp, ok := <-ch
167+ if !ok {
168+ return
169+ }
170+ out = frame(serial, callSerial, rp.sig, rp.body)
171+ }
172+ serial++
173+ if _, err = c.Write(out); err != nil {
174+ return
175+ }
176+ }
177+ }()
178+ return "unix:path=" + sock, ch
179+}
180+
181+// rawDial connects to a rawBus.
182+func rawDial(t testing.TB, addr string) *zerobus.Conn {
183+ t.Helper()
184+ c, err := zerobus.Dial(addr)
185+ if err != nil {
186+ t.Fatal(err)
187+ }
188+ t.Cleanup(func() { _ = c.Close() })
189+ return c
190+}
@@ -0,0 +1,190 @@
1+package tray
2+
3+import (
4+ "bufio"
5+ "encoding/binary"
6+ "io"
7+ "net"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/bnema/zerobus"
12+)
13+
14+// wb builds D-Bus wire data by hand (little endian), so tests can send bodies
15+// that zerobus's own encoder refuses to produce: truncated or inconsistent.
16+type wb struct{ b []byte }
17+
18+func (w *wb) pad(n int) {
19+ for len(w.b)%n != 0 {
20+ w.b = append(w.b, 0)
21+ }
22+}
23+
24+func (w *wb) u32(v uint32) { w.pad(4); w.b = binary.LittleEndian.AppendUint32(w.b, v) }
25+func (w *wb) i32(v int32) { w.u32(uint32(v)) }
26+
27+func (w *wb) str(s string) {
28+ w.u32(uint32(len(s)))
29+ w.b = append(append(w.b, s...), 0)
30+}
31+
32+func (w *wb) sig(s string) { w.b = append(append(w.b, byte(len(s))), append([]byte(s), 0)...) }
33+
34+// lenPrefixed writes an array whose data starts aligned to align and is filled by fn.
35+func (w *wb) lenPrefixed(align int, fn func()) {
36+ w.u32(0)
37+ at := len(w.b) - 4
38+ w.pad(align)
39+ start := len(w.b)
40+ fn()
41+ binary.LittleEndian.PutUint32(w.b[at:], uint32(len(w.b)-start))
42+}
43+
44+// layoutBody is the body of a GetLayout reply for root: the revision, then the tree.
45+func layoutBody(root *tnode) []byte {
46+ var w wb
47+ w.u32(1)
48+ w.node(root)
49+ return w.b
50+}
51+
52+func (w *wb) node(n *tnode) {
53+ w.pad(8)
54+ w.i32(n.id)
55+ w.lenPrefixed(8, func() {
56+ for _, p := range n.props {
57+ w.pad(8)
58+ w.str(p.k)
59+ switch v := p.v.(type) {
60+ case string:
61+ w.sig("s")
62+ w.str(v)
63+ case bool:
64+ w.sig("b")
65+ if v {
66+ w.u32(1)
67+ } else {
68+ w.u32(0)
69+ }
70+ case int32:
71+ w.sig("i")
72+ w.i32(v)
73+ }
74+ }
75+ })
76+ w.lenPrefixed(1, func() {
77+ if n.junk {
78+ w.sig("s")
79+ w.str("not a node")
80+ }
81+ for i := range n.kids {
82+ w.sig(layoutSig)
83+ w.node(&n.kids[i])
84+ }
85+ })
86+}
87+
88+// frame is a method return of the given serials, with signature sig and body.
89+func frame(serial, replySerial uint32, sig string, body []byte) []byte {
90+ // Header field 5 (REPLY_SERIAL, u) and 8 (SIGNATURE, g).
91+ var fields wb
92+ fields.pad(8)
93+ fields.b = append(fields.b, 5)
94+ fields.sig("u")
95+ fields.u32(replySerial)
96+ if sig != "" {
97+ fields.pad(8)
98+ fields.b = append(fields.b, 8)
99+ fields.sig("g")
100+ fields.sig(sig)
101+ }
102+ h := []byte{'l', 2, 0, 1}
103+ h = binary.LittleEndian.AppendUint32(h, uint32(len(body)))
104+ h = binary.LittleEndian.AppendUint32(h, serial)
105+ h = binary.LittleEndian.AppendUint32(h, uint32(len(fields.b)))
106+ h = append(h, fields.b...)
107+ for len(h)%8 != 0 {
108+ h = append(h, 0)
109+ }
110+ return append(h, body...)
111+}
112+
113+// reply is what the raw bus answers to the next method call.
114+type reply struct {
115+ sig string
116+ body []byte
117+}
118+
119+// rawBus is a bus with one client that answers every method call after Hello
120+// with the next queued reply. It returns the address to dial.
121+func rawBus(t testing.TB) (addr string, next chan<- reply) {
122+ t.Helper()
123+ sock := filepath.Join(t.TempDir(), "bus")
124+ l, err := net.Listen("unix", sock)
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ t.Cleanup(func() { _ = l.Close() })
129+ ch := make(chan reply, 16)
130+ go func() {
131+ c, err := l.Accept()
132+ if err != nil {
133+ return
134+ }
135+ defer c.Close()
136+ r := bufio.NewReader(c)
137+ if _, err = r.ReadString('\n'); err != nil { // \0AUTH EXTERNAL ...
138+ return
139+ }
140+ if _, err = io.WriteString(c, "OK 0123456789abcdef\r\n"); err != nil {
141+ return
142+ }
143+ if _, err = r.ReadString('\n'); err != nil { // BEGIN
144+ return
145+ }
146+ var hello wb
147+ hello.str(":1.7")
148+ serial := uint32(1)
149+ first := true
150+ for {
151+ var hdr [16]byte
152+ if _, err = io.ReadFull(r, hdr[:]); err != nil {
153+ return
154+ }
155+ fields := binary.LittleEndian.Uint32(hdr[12:])
156+ rest := (16+int(fields)+7)/8*8 - 16 + int(binary.LittleEndian.Uint32(hdr[4:]))
157+ if _, err = io.CopyN(io.Discard, r, int64(rest)); err != nil {
158+ return
159+ }
160+ callSerial := binary.LittleEndian.Uint32(hdr[8:])
161+ var out []byte
162+ if first {
163+ first = false
164+ out = frame(serial, callSerial, "s", hello.b)
165+ } else {
166+ rp, ok := <-ch
167+ if !ok {
168+ return
169+ }
170+ out = frame(serial, callSerial, rp.sig, rp.body)
171+ }
172+ serial++
173+ if _, err = c.Write(out); err != nil {
174+ return
175+ }
176+ }
177+ }()
178+ return "unix:path=" + sock, ch
179+}
180+
181+// rawDial connects to a rawBus.
182+func rawDial(t testing.TB, addr string) *zerobus.Conn {
183+ t.Helper()
184+ c, err := zerobus.Dial(addr)
185+ if err != nil {
186+ t.Fatal(err)
187+ }
188+ t.Cleanup(func() { _ = c.Close() })
189+ return c
190+}
M internal/tray/tray_test.go +1 -0
@@ -75,6 +75,7 @@ type fakeItem struct {
7575 // A dbusmenu at /MenuBar, set through run.
7676 menu *tnode
7777 mcalls chan string // the dbusmenu calls it received
78+ badSig bool // GetLayout answers with a string instead of a layout, set through run
7879 mslow map[string]time.Duration // dbusmenu methods it answers only after this long, set through run
7980 }
8081
@@ -75,6 +75,7 @@ type fakeItem struct {
75 // A dbusmenu at /MenuBar, set through run.75 // A dbusmenu at /MenuBar, set through run.
76 menu *tnode76 menu *tnode
77 mcalls chan string // the dbusmenu calls it received77 mcalls chan string // the dbusmenu calls it received
78+ badSig bool // GetLayout answers with a string instead of a layout, set through run
78 mslow map[string]time.Duration // dbusmenu methods it answers only after this long, set through run79 mslow map[string]time.Duration // dbusmenu methods it answers only after this long, set through run
79 }80 }
80 81

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 5

Tags 4

↑↓ moveenter openesc close