detached 19bbdfa

fix(cursor): check cursor buffer pitch and caps, validate Xcursor chunks

The cursor plane uses the smaller driver cap and refuses a padded dumb pitch, since the cursor ioctl assumes width*4. Xcursor chunks must match their table entry, Inherits is matched as a whole key, and the headless blend clamps non-premultiplied pixels. Adds a Decode fuzz target and an Inherits cycle test.
commit
19bbdfa0fb1e1ee628c0af9bd443efccdf9be5e0
author
brice · · 2026-09-24 15:25 UTC
parent
c8e5e67
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +62 -15
M internal/adapters/drm/cursor.go +24 -6
@@ -1,6 +1,7 @@
11 package drm
22
33 import (
4+ "fmt"
45 "sync"
56 "unsafe"
67 )
@@ -43,17 +44,28 @@ type Cursor struct {
4344 // newCursor allocates a cursor buffer at the driver's preferred size (64 when
4445 // the driver does not say).
4546 func newCursor(fd int, crtc uint32) (*Cursor, error) {
46- size := 64
47+ // The plane is square: the smaller of the width and height caps.
48+ size := 0
4749 for _, c := range []uint64{capCursorW, capCursorH} {
4850 v := getCap{capability: c}
49- if ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value >= 32 && v.value <= 512 {
50- size = max(size, int(v.value))
51+ if ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value >= 16 && v.value <= 512 {
52+ if size == 0 || int(v.value) < size {
53+ size = int(v.value)
54+ }
5155 }
5256 }
57+ if size == 0 {
58+ size = 64
59+ }
5360 buf, err := newDumb(fd, size, size)
5461 if err != nil {
5562 return nil, err
5663 }
64+ // The cursor ioctl passes no pitch: drivers assume width*4.
65+ if int(buf.pitch) != size*4 {
66+ buf.destroy(fd)
67+ return nil, fmt.Errorf("cursor buffer pitch %d, want %d", buf.pitch, size*4)
68+ }
5769 return &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}, nil
5870 }
5971
@@ -65,6 +77,9 @@ func (c *Cursor) Limit() int { return c.size }
6577 func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
6678 c.mu.Lock()
6779 defer c.mu.Unlock()
80+ if c.buf == nil {
81+ return nil
82+ }
6883 pitch := int(c.buf.pitch)
6984 clear(c.buf.mem)
7085 for y := range min(h, c.size) {
@@ -77,7 +92,7 @@ func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
7792
7893 // apply (re)attaches the buffer at the current position.
7994 func (c *Cursor) apply() error {
80- if !c.shown {
95+ if !c.shown || c.buf == nil {
8196 return nil
8297 }
8398 v := modeCursor2{flags: cursorBO | cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY), width: uint32(c.size), height: uint32(c.size), handle: c.buf.handle, hotX: int32(c.hotX), hotY: int32(c.hotY)}
@@ -90,7 +105,7 @@ func (c *Cursor) Move(x, y float64) {
90105 c.mu.Lock()
91106 defer c.mu.Unlock()
92107 c.x, c.y = int(x), int(y)
93- if !c.shown {
108+ if !c.shown || c.buf == nil {
94109 return
95110 }
96111 v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY)}
@@ -108,9 +123,12 @@ func (c *Cursor) Reapply() error {
108123 func (c *Cursor) close() {
109124 c.mu.Lock()
110125 defer c.mu.Unlock()
126+ if c.buf == nil {
127+ return
128+ }
111129 // Best effort: at exit the card may already belong to another session.
112130 v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
113131 _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
114132 c.buf.destroy(c.fd)
115- c.shown = false
133+ c.buf, c.shown = nil, false
116134 }
@@ -1,6 +1,7 @@
1 package drm1 package drm
2 2
3 import (3 import (
4+ "fmt"
4 "sync"5 "sync"
5 "unsafe"6 "unsafe"
6 )7 )
@@ -43,17 +44,28 @@ type Cursor struct {
43 // newCursor allocates a cursor buffer at the driver's preferred size (64 when44 // newCursor allocates a cursor buffer at the driver's preferred size (64 when
44 // the driver does not say).45 // the driver does not say).
45 func newCursor(fd int, crtc uint32) (*Cursor, error) {46 func newCursor(fd int, crtc uint32) (*Cursor, error) {
46- size := 6447+ // The plane is square: the smaller of the width and height caps.
48+ size := 0
47 for _, c := range []uint64{capCursorW, capCursorH} {49 for _, c := range []uint64{capCursorW, capCursorH} {
48 v := getCap{capability: c}50 v := getCap{capability: c}
49- if ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value >= 32 && v.value <= 512 {51+ if ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value >= 16 && v.value <= 512 {
50- size = max(size, int(v.value))52+ if size == 0 || int(v.value) < size {
53+ size = int(v.value)
54+ }
51 }55 }
52 }56 }
57+ if size == 0 {
58+ size = 64
59+ }
53 buf, err := newDumb(fd, size, size)60 buf, err := newDumb(fd, size, size)
54 if err != nil {61 if err != nil {
55 return nil, err62 return nil, err
56 }63 }
64+ // The cursor ioctl passes no pitch: drivers assume width*4.
65+ if int(buf.pitch) != size*4 {
66+ buf.destroy(fd)
67+ return nil, fmt.Errorf("cursor buffer pitch %d, want %d", buf.pitch, size*4)
68+ }
57 return &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}, nil69 return &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}, nil
58 }70 }
59 71
@@ -65,6 +77,9 @@ func (c *Cursor) Limit() int { return c.size }
65 func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {77 func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
66 c.mu.Lock()78 c.mu.Lock()
67 defer c.mu.Unlock()79 defer c.mu.Unlock()
80+ if c.buf == nil {
81+ return nil
82+ }
68 pitch := int(c.buf.pitch)83 pitch := int(c.buf.pitch)
69 clear(c.buf.mem)84 clear(c.buf.mem)
70 for y := range min(h, c.size) {85 for y := range min(h, c.size) {
@@ -77,7 +92,7 @@ func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
77 92
78 // apply (re)attaches the buffer at the current position.93 // apply (re)attaches the buffer at the current position.
79 func (c *Cursor) apply() error {94 func (c *Cursor) apply() error {
80- if !c.shown {95+ if !c.shown || c.buf == nil {
81 return nil96 return nil
82 }97 }
83 v := modeCursor2{flags: cursorBO | cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY), width: uint32(c.size), height: uint32(c.size), handle: c.buf.handle, hotX: int32(c.hotX), hotY: int32(c.hotY)}98 v := modeCursor2{flags: cursorBO | cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY), width: uint32(c.size), height: uint32(c.size), handle: c.buf.handle, hotX: int32(c.hotX), hotY: int32(c.hotY)}
@@ -90,7 +105,7 @@ func (c *Cursor) Move(x, y float64) {
90 c.mu.Lock()105 c.mu.Lock()
91 defer c.mu.Unlock()106 defer c.mu.Unlock()
92 c.x, c.y = int(x), int(y)107 c.x, c.y = int(x), int(y)
93- if !c.shown {108+ if !c.shown || c.buf == nil {
94 return109 return
95 }110 }
96 v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY)}111 v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY)}
@@ -108,9 +123,12 @@ func (c *Cursor) Reapply() error {
108 func (c *Cursor) close() {123 func (c *Cursor) close() {
109 c.mu.Lock()124 c.mu.Lock()
110 defer c.mu.Unlock()125 defer c.mu.Unlock()
126+ if c.buf == nil {
127+ return
128+ }
111 // Best effort: at exit the card may already belong to another session.129 // Best effort: at exit the card may already belong to another session.
112 v := modeCursor2{flags: cursorBO, crtcID: c.crtc}130 v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
113 _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))131 _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
114 c.buf.destroy(c.fd)132 c.buf.destroy(c.fd)
115- c.shown = false133+ c.buf, c.shown = nil, false
116 }134 }
M internal/adapters/drm/output.go +1 -1
@@ -176,7 +176,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
176176 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
177177 var scene ports.Scene
178178 haveScene, dirty, enabled := false, false, true
179- cursorScale := 0.0
179+ cursorScale := -1.0 // not loaded yet
180180 frame := 0
181181 stats := time.NewTicker(10 * time.Second)
182182 defer stats.Stop()
@@ -176,7 +176,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
176 surfaces := make(map[ports.WindowID]ports.SurfaceContent)176 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
177 var scene ports.Scene177 var scene ports.Scene
178 haveScene, dirty, enabled := false, false, true178 haveScene, dirty, enabled := false, false, true
179- cursorScale := 0.0179+ cursorScale := -1.0 // not loaded yet
180 frame := 0180 frame := 0
181 stats := time.NewTicker(10 * time.Second)181 stats := time.NewTicker(10 * time.Second)
182 defer stats.Stop()182 defer stats.Stop()
M internal/adapters/headless/cursor.go +4 -3
@@ -47,9 +47,10 @@ func (c *Cursor) draw(dst *image.RGBA) {
4747 }
4848 d := dst.Pix[dst.PixOffset(dx, dy):]
4949 // Source is premultiplied B,G,R,A; dst is straight R,G,B,A (opaque).
50- d[0] = byte(int(s[2]) + int(d[0])*(255-a)/255)
51- d[1] = byte(int(s[1]) + int(d[1])*(255-a)/255)
52- d[2] = byte(int(s[0]) + int(d[2])*(255-a)/255)
50+ // min guards against themes that are not really premultiplied.
51+ d[0] = byte(min(255, int(s[2])+int(d[0])*(255-a)/255))
52+ d[1] = byte(min(255, int(s[1])+int(d[1])*(255-a)/255))
53+ d[2] = byte(min(255, int(s[0])+int(d[2])*(255-a)/255))
5354 }
5455 }
5556 }
@@ -47,9 +47,10 @@ func (c *Cursor) draw(dst *image.RGBA) {
47 }47 }
48 d := dst.Pix[dst.PixOffset(dx, dy):]48 d := dst.Pix[dst.PixOffset(dx, dy):]
49 // Source is premultiplied B,G,R,A; dst is straight R,G,B,A (opaque).49 // Source is premultiplied B,G,R,A; dst is straight R,G,B,A (opaque).
50- d[0] = byte(int(s[2]) + int(d[0])*(255-a)/255)50+ // min guards against themes that are not really premultiplied.
51- d[1] = byte(int(s[1]) + int(d[1])*(255-a)/255)51+ d[0] = byte(min(255, int(s[2])+int(d[0])*(255-a)/255))
52- d[2] = byte(int(s[0]) + int(d[2])*(255-a)/255)52+ d[1] = byte(min(255, int(s[1])+int(d[1])*(255-a)/255))
53+ d[2] = byte(min(255, int(s[0])+int(d[2])*(255-a)/255))
53 }54 }
54 }55 }
55 }56 }
M internal/adapters/headless/output.go +1 -1
@@ -40,7 +40,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
4040 var scene ports.Scene
4141 haveScene := false
4242 frame := 0
43- cursorScale := 0.0
43+ cursorScale := -1.0 // not loaded yet
4444 update := func(c ports.SurfaceContent) {
4545 if c.Pixels == nil {
4646 delete(surfaces, c.ID)
@@ -40,7 +40,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
40 var scene ports.Scene40 var scene ports.Scene
41 haveScene := false41 haveScene := false
42 frame := 042 frame := 0
43- cursorScale := 0.043+ cursorScale := -1.0 // not loaded yet
44 update := func(c ports.SurfaceContent) {44 update := func(c ports.SurfaceContent) {
45 if c.Pixels == nil {45 if c.Pixels == nil {
46 delete(surfaces, c.ID)46 delete(surfaces, c.ID)
M internal/adapters/xcursor/xcursor.go +6 -4
@@ -63,6 +63,10 @@ func Decode(data []byte, size int) (Image, error) {
6363 return Image{}, errors.New("truncated image header")
6464 }
6565 h := data[best:]
66+ // The chunk header must repeat its table-of-contents entry.
67+ if le.Uint32(h) != 36 || le.Uint32(h[4:]) != chunkImage || le.Uint32(h[8:]) != bestSize {
68+ return Image{}, errors.New("image chunk does not match its table entry")
69+ }
6670 w, ht := le.Uint32(h[16:]), le.Uint32(h[20:])
6771 if w == 0 || ht == 0 || w > maxSide || ht > maxSide {
6872 return Image{}, fmt.Errorf("bad image size %dx%d", w, ht)
@@ -170,10 +174,8 @@ func inherits(dirs []string, theme string) []string {
170174 defer f.Close()
171175 s := bufio.NewScanner(f)
172176 for s.Scan() {
173- if v, ok := strings.CutPrefix(strings.TrimSpace(s.Text()), "Inherits"); ok {
174- if v, ok := strings.CutPrefix(strings.TrimSpace(v), "="); ok {
175- return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == ' ' })
176- }
177+ if k, v, ok := strings.Cut(s.Text(), "="); ok && strings.TrimSpace(k) == "Inherits" {
178+ return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == ' ' })
177179 }
178180 }
179181 return nil
@@ -63,6 +63,10 @@ func Decode(data []byte, size int) (Image, error) {
63 return Image{}, errors.New("truncated image header")63 return Image{}, errors.New("truncated image header")
64 }64 }
65 h := data[best:]65 h := data[best:]
66+ // The chunk header must repeat its table-of-contents entry.
67+ if le.Uint32(h) != 36 || le.Uint32(h[4:]) != chunkImage || le.Uint32(h[8:]) != bestSize {
68+ return Image{}, errors.New("image chunk does not match its table entry")
69+ }
66 w, ht := le.Uint32(h[16:]), le.Uint32(h[20:])70 w, ht := le.Uint32(h[16:]), le.Uint32(h[20:])
67 if w == 0 || ht == 0 || w > maxSide || ht > maxSide {71 if w == 0 || ht == 0 || w > maxSide || ht > maxSide {
68 return Image{}, fmt.Errorf("bad image size %dx%d", w, ht)72 return Image{}, fmt.Errorf("bad image size %dx%d", w, ht)
@@ -170,10 +174,8 @@ func inherits(dirs []string, theme string) []string {
170 defer f.Close()174 defer f.Close()
171 s := bufio.NewScanner(f)175 s := bufio.NewScanner(f)
172 for s.Scan() {176 for s.Scan() {
173- if v, ok := strings.CutPrefix(strings.TrimSpace(s.Text()), "Inherits"); ok {177+ if k, v, ok := strings.Cut(s.Text(), "="); ok && strings.TrimSpace(k) == "Inherits" {
174- if v, ok := strings.CutPrefix(strings.TrimSpace(v), "="); ok {178+ return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == ' ' })
175- return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == ' ' })
176- }
177 }179 }
178 }180 }
179 return nil181 return nil
M internal/adapters/xcursor/xcursor_test.go +26 -0
@@ -101,3 +101,29 @@ func TestSystemTheme(t *testing.T) {
101101 t.Fatal(img.W)
102102 }
103103 }
104+
105+func FuzzDecode(f *testing.F) {
106+ f.Add(file(24, 48), 24)
107+ f.Add(file(32), 64)
108+ f.Fuzz(func(t *testing.T, data []byte, size int) {
109+ img, err := Decode(data, size)
110+ if err == nil && (len(img.Pixels) != img.W*img.H*4 || img.HotX >= img.W || img.HotY >= img.H) {
111+ t.Fatalf("%dx%d hot %d,%d len %d", img.W, img.H, img.HotX, img.HotY, len(img.Pixels))
112+ }
113+ })
114+}
115+
116+func TestLoadInheritsCycle(t *testing.T) {
117+ dir := t.TempDir()
118+ for theme, parent := range map[string]string{"a": "b", "b": "a"} {
119+ if err := os.MkdirAll(filepath.Join(dir, theme), 0o755); err != nil {
120+ t.Fatal(err)
121+ }
122+ if err := os.WriteFile(filepath.Join(dir, theme, "index.theme"), []byte("[Icon Theme]\nInherits="+parent+"\n"), 0o644); err != nil {
123+ t.Fatal(err)
124+ }
125+ }
126+ if _, err := Load([]string{dir}, "a", []string{"default"}, 24); err == nil {
127+ t.Fatal("found a cursor in an empty cycle")
128+ }
129+}
@@ -101,3 +101,29 @@ func TestSystemTheme(t *testing.T) {
101 t.Fatal(img.W)101 t.Fatal(img.W)
102 }102 }
103 }103 }
104+
105+func FuzzDecode(f *testing.F) {
106+ f.Add(file(24, 48), 24)
107+ f.Add(file(32), 64)
108+ f.Fuzz(func(t *testing.T, data []byte, size int) {
109+ img, err := Decode(data, size)
110+ if err == nil && (len(img.Pixels) != img.W*img.H*4 || img.HotX >= img.W || img.HotY >= img.H) {
111+ t.Fatalf("%dx%d hot %d,%d len %d", img.W, img.H, img.HotX, img.HotY, len(img.Pixels))
112+ }
113+ })
114+}
115+
116+func TestLoadInheritsCycle(t *testing.T) {
117+ dir := t.TempDir()
118+ for theme, parent := range map[string]string{"a": "b", "b": "a"} {
119+ if err := os.MkdirAll(filepath.Join(dir, theme), 0o755); err != nil {
120+ t.Fatal(err)
121+ }
122+ if err := os.WriteFile(filepath.Join(dir, theme, "index.theme"), []byte("[Icon Theme]\nInherits="+parent+"\n"), 0o644); err != nil {
123+ t.Fatal(err)
124+ }
125+ }
126+ if _, err := Load([]string{dir}, "a", []string{"default"}, 24); err == nil {
127+ t.Fatal("found a cursor in an empty cycle")
128+ }
129+}

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 25

Tags 10

↑↓ moveenter openesc close