detached 27f82c1

fix(tray): reject invalid bus names and drop stale watcher lists

- RegisterStatusNotifierItem rejects a service that is neither an object
  path nor a valid bus name.
- When the watcher is replaced, a late item list from the old one is
  ignored.
- The .desktop index reloads when an applications directory mtime differs
  from the one seen at load time, so clock skew cannot force rescans.
- Icon sampling uses an odd pixel step, so large icons are not sampled in
  a few columns only.
- Quote tray.icons keys in config errors.
commit
27f82c1f72ba1ddd1f5832d041fce1b1809c0e2a
author
brice · · 2026-10-08 20:15 UTC
parent
e79294b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +105 -20
M internal/config/config.go +1 -1
@@ -140,7 +140,7 @@ func (c *Config) Validate() error {
140140 for k, v := range c.Tray.Icons {
141141 // The tray prints the value on its one output line.
142142 if strings.ContainsFunc(v, unicode.IsControl) {
143- return fmt.Errorf("config: tray.icons.%s contains a control character", k)
143+ return fmt.Errorf("config: tray.icons.%q contains a control character", k)
144144 }
145145 }
146146 return nil
@@ -140,7 +140,7 @@ func (c *Config) Validate() error {
140 for k, v := range c.Tray.Icons {140 for k, v := range c.Tray.Icons {
141 // The tray prints the value on its one output line.141 // The tray prints the value on its one output line.
142 if strings.ContainsFunc(v, unicode.IsControl) {142 if strings.ContainsFunc(v, unicode.IsControl) {
143- return fmt.Errorf("config: tray.icons.%s contains a control character", k)143+ return fmt.Errorf("config: tray.icons.%q contains a control character", k)
144 }144 }
145 }145 }
146 return nil146 return nil
M internal/tray/color.go +7 -2
@@ -29,8 +29,13 @@ func (h *histogram) dominant(argb []byte) (RGB, bool) {
2929 }
3030 clear(h.weight[:])
3131 clear(h.sum[:])
32- step := 4 * ((len(argb)/4 + maxSamples - 1) / maxSamples)
33- for i := 0; i+3 < len(argb); i += step {
32+ // An odd pixel step crosses every column of a power-of-two wide icon,
33+ // so samples do not line up in a few columns.
34+ step := (len(argb)/4 + maxSamples - 1) / maxSamples
35+ if step > 1 {
36+ step |= 1
37+ }
38+ for i := 0; i+3 < len(argb); i += 4 * step {
3439 a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3])
3540 if a < 128 {
3641 continue
@@ -29,8 +29,13 @@ func (h *histogram) dominant(argb []byte) (RGB, bool) {
29 }29 }
30 clear(h.weight[:])30 clear(h.weight[:])
31 clear(h.sum[:])31 clear(h.sum[:])
32- step := 4 * ((len(argb)/4 + maxSamples - 1) / maxSamples)32+ // An odd pixel step crosses every column of a power-of-two wide icon,
33- for i := 0; i+3 < len(argb); i += step {33+ // so samples do not line up in a few columns.
34+ step := (len(argb)/4 + maxSamples - 1) / maxSamples
35+ if step > 1 {
36+ step |= 1
37+ }
38+ for i := 0; i+3 < len(argb); i += 4 * step {
34 a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3])39 a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3])
35 if a < 128 {40 if a < 128 {
36 continue41 continue
M internal/tray/color_test.go +20 -0
@@ -45,6 +45,26 @@ func TestDominantLargeIcon(t *testing.T) {
4545 }
4646 }
4747
48+func TestDominantLargeIconOffColumns(t *testing.T) {
49+ // 512x512, transparent but for a red logo in columns 10..49: a stride of
50+ // 64 pixels would sample only columns 0, 64, 128... and miss it.
51+ const side = 512
52+ icon := make([]byte, 0, side*side*4)
53+ for range side {
54+ for x := range side {
55+ if x >= 10 && x < 50 {
56+ icon = append(icon, 255, 250, 20, 20)
57+ } else {
58+ icon = append(icon, 0, 0, 0, 0)
59+ }
60+ }
61+ }
62+ var h histogram
63+ if got, ok := h.dominant(icon); !ok || got != (RGB{250, 20, 20}) {
64+ t.Fatalf("dominant = %v, %v", got, ok)
65+ }
66+}
67+
4868 func TestReadable(t *testing.T) {
4969 bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4}
5070 dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue
@@ -45,6 +45,26 @@ func TestDominantLargeIcon(t *testing.T) {
45 }45 }
46 }46 }
47 47
48+func TestDominantLargeIconOffColumns(t *testing.T) {
49+ // 512x512, transparent but for a red logo in columns 10..49: a stride of
50+ // 64 pixels would sample only columns 0, 64, 128... and miss it.
51+ const side = 512
52+ icon := make([]byte, 0, side*side*4)
53+ for range side {
54+ for x := range side {
55+ if x >= 10 && x < 50 {
56+ icon = append(icon, 255, 250, 20, 20)
57+ } else {
58+ icon = append(icon, 0, 0, 0, 0)
59+ }
60+ }
61+ }
62+ var h histogram
63+ if got, ok := h.dominant(icon); !ok || got != (RGB{250, 20, 20}) {
64+ t.Fatalf("dominant = %v, %v", got, ok)
65+ }
66+}
67+
48 func TestReadable(t *testing.T) {68 func TestReadable(t *testing.T) {
49 bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4}69 bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4}
50 dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue70 dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue
M internal/tray/resolve.go +21 -9
@@ -22,7 +22,7 @@ type Resolver struct {
2222 icons map[string]string // lowercased keys
2323 dataDirs []string
2424 apps map[string]*desktopApp // nil until the first lookup
25- loaded time.Time // when apps was read
25+ mtimes []time.Time // of each applications dir when apps was read
2626 }
2727
2828 // desktopApp is what the resolver uses from a .desktop file.
@@ -259,10 +259,23 @@ func (r *Resolver) app(id, iconName, title string) *desktopApp {
259259 }
260260
261261 func (r *Resolver) reload() {
262- r.loaded = time.Now()
262+ r.mtimes = r.dirTimes(r.mtimes[:0])
263263 r.apps = loadApps(r.dataDirs)
264264 }
265265
266+// dirTimes appends the mtime of each applications directory, zero when it
267+// is missing.
268+func (r *Resolver) dirTimes(ts []time.Time) []time.Time {
269+ for _, d := range r.dataDirs {
270+ var t time.Time
271+ if st, err := os.Stat(filepath.Join(d, "applications")); d != "" && err == nil {
272+ t = st.ModTime()
273+ }
274+ ts = append(ts, t)
275+ }
276+ return ts
277+}
278+
266279 // findApp looks up the whole id, icon name and title, then the words of the
267280 // id and icon name. Titles are free text: their words are not used.
268281 func (r *Resolver) findApp(id, iconName, title string) *desktopApp {
@@ -281,14 +294,13 @@ func (r *Resolver) findApp(id, iconName, title string) *desktopApp {
281294 return nil
282295 }
283296
284-// changed reports whether an applications directory was modified after the
285-// index was read.
297+// changed reports whether an applications directory changed since the
298+// index was read. Comparing mtimes with themselves, not with the clock,
299+// is safe from clock skew.
286300 func (r *Resolver) changed() bool {
287- for _, d := range r.dataDirs {
288- if d == "" {
289- continue
290- }
291- if st, err := os.Stat(filepath.Join(d, "applications")); err == nil && !st.ModTime().Before(r.loaded) {
301+ var buf [8]time.Time
302+ for i, t := range r.dirTimes(buf[:0]) {
303+ if !t.Equal(r.mtimes[i]) {
292304 return true
293305 }
294306 }
@@ -22,7 +22,7 @@ type Resolver struct {
22 icons map[string]string // lowercased keys22 icons map[string]string // lowercased keys
23 dataDirs []string23 dataDirs []string
24 apps map[string]*desktopApp // nil until the first lookup24 apps map[string]*desktopApp // nil until the first lookup
25- loaded time.Time // when apps was read25+ mtimes []time.Time // of each applications dir when apps was read
26 }26 }
27 27
28 // desktopApp is what the resolver uses from a .desktop file.28 // desktopApp is what the resolver uses from a .desktop file.
@@ -259,10 +259,23 @@ func (r *Resolver) app(id, iconName, title string) *desktopApp {
259 }259 }
260 260
261 func (r *Resolver) reload() {261 func (r *Resolver) reload() {
262- r.loaded = time.Now()262+ r.mtimes = r.dirTimes(r.mtimes[:0])
263 r.apps = loadApps(r.dataDirs)263 r.apps = loadApps(r.dataDirs)
264 }264 }
265 265
266+// dirTimes appends the mtime of each applications directory, zero when it
267+// is missing.
268+func (r *Resolver) dirTimes(ts []time.Time) []time.Time {
269+ for _, d := range r.dataDirs {
270+ var t time.Time
271+ if st, err := os.Stat(filepath.Join(d, "applications")); d != "" && err == nil {
272+ t = st.ModTime()
273+ }
274+ ts = append(ts, t)
275+ }
276+ return ts
277+}
278+
266 // findApp looks up the whole id, icon name and title, then the words of the279 // findApp looks up the whole id, icon name and title, then the words of the
267 // id and icon name. Titles are free text: their words are not used.280 // id and icon name. Titles are free text: their words are not used.
268 func (r *Resolver) findApp(id, iconName, title string) *desktopApp {281 func (r *Resolver) findApp(id, iconName, title string) *desktopApp {
@@ -281,14 +294,13 @@ func (r *Resolver) findApp(id, iconName, title string) *desktopApp {
281 return nil294 return nil
282 }295 }
283 296
284-// changed reports whether an applications directory was modified after the297+// changed reports whether an applications directory changed since the
285-// index was read.298+// index was read. Comparing mtimes with themselves, not with the clock,
299+// is safe from clock skew.
286 func (r *Resolver) changed() bool {300 func (r *Resolver) changed() bool {
287- for _, d := range r.dataDirs {301+ var buf [8]time.Time
288- if d == "" {302+ for i, t := range r.dirTimes(buf[:0]) {
289- continue303+ if !t.Equal(r.mtimes[i]) {
290- }
291- if st, err := os.Stat(filepath.Join(d, "applications")); err == nil && !st.ModTime().Before(r.loaded) {
292 return true304 return true
293 }305 }
294 }306 }
M internal/tray/resolve_test.go +19 -3
@@ -93,9 +93,10 @@ func TestResolveFindsAppsInstalledLater(t *testing.T) {
9393 t.Fatalf("before install: %q", got)
9494 }
9595 writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
96- // The directory's mtime must move past the load time.
97- future := time.Now().Add(time.Second)
98- if err := os.Chtimes(filepath.Join(dir, "applications"), future, future); err != nil {
96+ // Make sure the directory's mtime differs from the one at the load,
97+ // even on a coarse clock.
98+ later := time.Now().Add(time.Hour)
99+ if err := os.Chtimes(filepath.Join(dir, "applications"), later, later); err != nil {
99100 t.Fatal(err)
100101 }
101102 if got := r.Resolve("racer", "", ""); got != "G" {
@@ -103,6 +104,21 @@ func TestResolveFindsAppsInstalledLater(t *testing.T) {
103104 }
104105 }
105106
107+func TestResolveDoesNotRescanUnchangedDirs(t *testing.T) {
108+ // A directory dated in the future does not make every miss rescan.
109+ dir := t.TempDir()
110+ writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
111+ future := time.Now().Add(24 * time.Hour)
112+ if err := os.Chtimes(filepath.Join(dir, "applications"), future, future); err != nil {
113+ t.Fatal(err)
114+ }
115+ r := NewResolver(lookup, nil, []string{dir})
116+ r.Resolve("unknown", "", "")
117+ if r.changed() {
118+ t.Fatal("an unchanged directory counts as changed")
119+ }
120+}
121+
106122 func TestEmptyOverrideHides(t *testing.T) {
107123 r := NewResolver(lookup, map[string]string{"steam": ""}, []string{t.TempDir()})
108124 if got := r.Resolve("steam", "", ""); got != "" {
@@ -93,9 +93,10 @@ func TestResolveFindsAppsInstalledLater(t *testing.T) {
93 t.Fatalf("before install: %q", got)93 t.Fatalf("before install: %q", got)
94 }94 }
95 writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")95 writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
96- // The directory's mtime must move past the load time.96+ // Make sure the directory's mtime differs from the one at the load,
97- future := time.Now().Add(time.Second)97+ // even on a coarse clock.
98- if err := os.Chtimes(filepath.Join(dir, "applications"), future, future); err != nil {98+ later := time.Now().Add(time.Hour)
99+ if err := os.Chtimes(filepath.Join(dir, "applications"), later, later); err != nil {
99 t.Fatal(err)100 t.Fatal(err)
100 }101 }
101 if got := r.Resolve("racer", "", ""); got != "G" {102 if got := r.Resolve("racer", "", ""); got != "G" {
@@ -103,6 +104,21 @@ func TestResolveFindsAppsInstalledLater(t *testing.T) {
103 }104 }
104 }105 }
105 106
107+func TestResolveDoesNotRescanUnchangedDirs(t *testing.T) {
108+ // A directory dated in the future does not make every miss rescan.
109+ dir := t.TempDir()
110+ writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
111+ future := time.Now().Add(24 * time.Hour)
112+ if err := os.Chtimes(filepath.Join(dir, "applications"), future, future); err != nil {
113+ t.Fatal(err)
114+ }
115+ r := NewResolver(lookup, nil, []string{dir})
116+ r.Resolve("unknown", "", "")
117+ if r.changed() {
118+ t.Fatal("an unchanged directory counts as changed")
119+ }
120+}
121+
106 func TestEmptyOverrideHides(t *testing.T) {122 func TestEmptyOverrideHides(t *testing.T) {
107 r := NewResolver(lookup, map[string]string{"steam": ""}, []string{t.TempDir()})123 r := NewResolver(lookup, map[string]string{"steam": ""}, []string{t.TempDir()})
108 if got := r.Resolve("steam", "", ""); got != "" {124 if got := r.Resolve("steam", "", ""); got != "" {
M internal/tray/tray.go +32 -2
@@ -284,7 +284,8 @@ func (t *Tray) signal(m *zerobus.Message) {
284284 t.vanished(name)
285285 case name == watcherName && old != "" && !t.watcher && owner != t.c.UniqueName():
286286 // Another watcher replaced the one we used: its items are
287- // the ones to show now.
287+ // the ones to show now, not a late list from the old one.
288+ t.pending = slices.DeleteFunc(t.pending, func(p pend) bool { return p.kind == pendItems })
288289 t.dropAll()
289290 t.becomeHost()
290291 }
@@ -356,6 +357,32 @@ func normService(s string) (service, bus, path string) {
356357 return s, bus, "/" + p
357358 }
358359
360+// validBusName reports whether s is a unique (":1.42") or well-known
361+// ("org.example.App") bus name, as D-Bus defines them.
362+func validBusName(s string) bool {
363+ if s == "" || len(s) > 255 {
364+ return false
365+ }
366+ unique := s[0] == ':'
367+ if unique {
368+ s = s[1:]
369+ }
370+ n := 0
371+ for more := true; more; n++ {
372+ var e string
373+ e, s, more = strings.Cut(s, ".")
374+ if e == "" || !unique && e[0] >= '0' && e[0] <= '9' {
375+ return false
376+ }
377+ for _, c := range []byte(e) {
378+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
379+ return false
380+ }
381+ }
382+ }
383+ return n >= 2
384+}
385+
359386 // addResult says what add did.
360387 type addResult uint8
361388
@@ -533,8 +560,11 @@ func (t *Tray) call(m *zerobus.Message) {
533560 }
534561 if strings.HasPrefix(s, "/") {
535562 s = m.Sender + s // registered by object path
536- } else {
563+ } else if validBusName(s) {
537564 s += itemPath
565+ } else {
566+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
567+ return
538568 }
539569 // add copies s before the next read, and sends only a GetAll.
540570 switch t.add(s, m.Sender) {
@@ -284,7 +284,8 @@ func (t *Tray) signal(m *zerobus.Message) {
284 t.vanished(name)284 t.vanished(name)
285 case name == watcherName && old != "" && !t.watcher && owner != t.c.UniqueName():285 case name == watcherName && old != "" && !t.watcher && owner != t.c.UniqueName():
286 // Another watcher replaced the one we used: its items are286 // Another watcher replaced the one we used: its items are
287- // the ones to show now.287+ // the ones to show now, not a late list from the old one.
288+ t.pending = slices.DeleteFunc(t.pending, func(p pend) bool { return p.kind == pendItems })
288 t.dropAll()289 t.dropAll()
289 t.becomeHost()290 t.becomeHost()
290 }291 }
@@ -356,6 +357,32 @@ func normService(s string) (service, bus, path string) {
356 return s, bus, "/" + p357 return s, bus, "/" + p
357 }358 }
358 359
360+// validBusName reports whether s is a unique (":1.42") or well-known
361+// ("org.example.App") bus name, as D-Bus defines them.
362+func validBusName(s string) bool {
363+ if s == "" || len(s) > 255 {
364+ return false
365+ }
366+ unique := s[0] == ':'
367+ if unique {
368+ s = s[1:]
369+ }
370+ n := 0
371+ for more := true; more; n++ {
372+ var e string
373+ e, s, more = strings.Cut(s, ".")
374+ if e == "" || !unique && e[0] >= '0' && e[0] <= '9' {
375+ return false
376+ }
377+ for _, c := range []byte(e) {
378+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
379+ return false
380+ }
381+ }
382+ }
383+ return n >= 2
384+}
385+
359 // addResult says what add did.386 // addResult says what add did.
360 type addResult uint8387 type addResult uint8
361 388
@@ -533,8 +560,11 @@ func (t *Tray) call(m *zerobus.Message) {
533 }560 }
534 if strings.HasPrefix(s, "/") {561 if strings.HasPrefix(s, "/") {
535 s = m.Sender + s // registered by object path562 s = m.Sender + s // registered by object path
536- } else {563+ } else if validBusName(s) {
537 s += itemPath564 s += itemPath
565+ } else {
566+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
567+ return
538 }568 }
539 // add copies s before the next read, and sends only a GetAll.569 // add copies s before the next read, and sends only a GetAll.
540 switch t.add(s, m.Sender) {570 switch t.add(s, m.Sender) {
M internal/tray/tray_test.go +5 -3
@@ -458,9 +458,11 @@ func TestWatcherRegistration(t *testing.T) {
458458
459459 // A path D-Bus refuses is rejected and leaves no trace.
460460 probe := dial(t, addr)
461- probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str("/bad//path")
462- if _, err := probe.Call(); err == nil {
463- t.Fatal("an invalid path was accepted")
461+ for _, bad := range []string{"/bad//path", "not a name", "1.starts.with.digit"} {
462+ probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(bad)
463+ if _, err := probe.Call(); err == nil {
464+ t.Fatalf("%q was accepted", bad)
465+ }
464466 }
465467 if items := registered(t, probe); len(items) != 1 || items[0] != steam.name+itemPath {
466468 t.Fatalf("registered items %q", items)
@@ -458,9 +458,11 @@ func TestWatcherRegistration(t *testing.T) {
458 458
459 // A path D-Bus refuses is rejected and leaves no trace.459 // A path D-Bus refuses is rejected and leaves no trace.
460 probe := dial(t, addr)460 probe := dial(t, addr)
461- probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str("/bad//path")461+ for _, bad := range []string{"/bad//path", "not a name", "1.starts.with.digit"} {
462- if _, err := probe.Call(); err == nil {462+ probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(bad)
463- t.Fatal("an invalid path was accepted")463+ if _, err := probe.Call(); err == nil {
464+ t.Fatalf("%q was accepted", bad)
465+ }
464 }466 }
465 if items := registered(t, probe); len(items) != 1 || items[0] != steam.name+itemPath {467 if items := registered(t, probe); len(items) != 1 || items[0] != steam.name+itemPath {
466 t.Fatalf("registered items %q", items)468 t.Fatalf("registered items %q", items)

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