detached 4e91a36

fix(keymap): copy bounded FD keymaps and verify independent references

commit
4e91a3635a1e53c4872b8c356e881b41d34dc717
author
brice · · 2026-09-28 07:08 UTC
parent
9e67979
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +216 -40
M loader.go +21 -12
@@ -14,25 +14,34 @@ var loader struct {
1414 err error
1515 }
1616
17+// resolveLibrary keeps path and required symbols injectable for diagnostic tests.
18+// On success it leaves the library open for the lifetime of registered functions.
19+func resolveLibrary(path string, symbols []string) (map[string]uintptr, error) {
20+ lib, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL)
21+ if err != nil {
22+ return nil, fmt.Errorf("xkbcommon: load %s: %w", path, err)
23+ }
24+ addresses := make(map[string]uintptr, len(symbols))
25+ for _, name := range symbols {
26+ address, e := purego.Dlsym(lib, name)
27+ if e != nil || address == 0 {
28+ _ = purego.Dlclose(lib)
29+ return nil, fmt.Errorf("xkbcommon: incompatible %s: missing %s: %v", path, name, e)
30+ }
31+ addresses[name] = address
32+ }
33+ return addresses, nil
34+}
35+
1736 // Available resolves the SONAME and every required symbol, reporting the first
1837 // incompatible symbol rather than failing later in a keyboard event callback.
1938 func Available() error {
2039 loader.Do(func() {
21- lib, err := purego.Dlopen("libxkbcommon.so.0", purego.RTLD_NOW|purego.RTLD_LOCAL)
40+ addresses, err := resolveLibrary("libxkbcommon.so.0", raw.Symbols)
2241 if err != nil {
23- loader.err = fmt.Errorf("xkbcommon: load libxkbcommon.so.0: %w", err)
42+ loader.err = err
2443 return
2544 }
26- addresses := make(map[string]uintptr, len(raw.Symbols))
27- for _, name := range raw.Symbols {
28- address, e := purego.Dlsym(lib, name)
29- if e != nil || address == 0 {
30- loader.err = fmt.Errorf("xkbcommon: incompatible libxkbcommon.so.0: missing %s: %v", name, e)
31- _ = purego.Dlclose(lib)
32- return
33- }
34- addresses[name] = address
35- }
3645 raw.Register(&loader.f, addresses, purego.RegisterFunc)
3746 // Intentionally keep the library open: registered function pointers and live
3847 // native objects remain valid for the process lifetime.
@@ -14,25 +14,34 @@ var loader struct {
14 err error14 err error
15 }15 }
16 16
17+// resolveLibrary keeps path and required symbols injectable for diagnostic tests.
18+// On success it leaves the library open for the lifetime of registered functions.
19+func resolveLibrary(path string, symbols []string) (map[string]uintptr, error) {
20+ lib, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL)
21+ if err != nil {
22+ return nil, fmt.Errorf("xkbcommon: load %s: %w", path, err)
23+ }
24+ addresses := make(map[string]uintptr, len(symbols))
25+ for _, name := range symbols {
26+ address, e := purego.Dlsym(lib, name)
27+ if e != nil || address == 0 {
28+ _ = purego.Dlclose(lib)
29+ return nil, fmt.Errorf("xkbcommon: incompatible %s: missing %s: %v", path, name, e)
30+ }
31+ addresses[name] = address
32+ }
33+ return addresses, nil
34+}
35+
17 // Available resolves the SONAME and every required symbol, reporting the first36 // Available resolves the SONAME and every required symbol, reporting the first
18 // incompatible symbol rather than failing later in a keyboard event callback.37 // incompatible symbol rather than failing later in a keyboard event callback.
19 func Available() error {38 func Available() error {
20 loader.Do(func() {39 loader.Do(func() {
21- lib, err := purego.Dlopen("libxkbcommon.so.0", purego.RTLD_NOW|purego.RTLD_LOCAL)40+ addresses, err := resolveLibrary("libxkbcommon.so.0", raw.Symbols)
22 if err != nil {41 if err != nil {
23- loader.err = fmt.Errorf("xkbcommon: load libxkbcommon.so.0: %w", err)42+ loader.err = err
24 return43 return
25 }44 }
26- addresses := make(map[string]uintptr, len(raw.Symbols))
27- for _, name := range raw.Symbols {
28- address, e := purego.Dlsym(lib, name)
29- if e != nil || address == 0 {
30- loader.err = fmt.Errorf("xkbcommon: incompatible libxkbcommon.so.0: missing %s: %v", name, e)
31- _ = purego.Dlclose(lib)
32- return
33- }
34- addresses[name] = address
35- }
36 raw.Register(&loader.f, addresses, purego.RegisterFunc)45 raw.Register(&loader.f, addresses, purego.RegisterFunc)
37 // Intentionally keep the library open: registered function pointers and live46 // Intentionally keep the library open: registered function pointers and live
38 // native objects remain valid for the process lifetime.47 // native objects remain valid for the process lifetime.
M xkbcommon.go +23 -15
@@ -129,31 +129,39 @@ func (c *Context) NewKeymapRules(rules, model, layout, variant, options string)
129129 })
130130 }
131131
132-// NewKeymapFD maps exactly size bytes read-only and privately, then compiles the
133-// Wayland xkb_v1 format. The caller retains ownership of fd, including on error;
134-// this function never closes it. The mapping is removed before return. size must
135-// include the terminating NUL required by the Wayland protocol.
132+// NewKeymapFD copies exactly size bytes from offset zero of a Wayland xkb_v1
133+// keymap FD (at most 16 MiB). A trailing Wayland NUL, if present, is excluded
134+// from the compiled text. The caller retains ownership of fd, even on error.
136135 func (c *Context) NewKeymapFD(fd int, size int) (*Keymap, error) {
137- if fd < 0 || size < 1 || size > 64<<20 {
138- return nil, errors.New("xkbcommon: invalid keymap FD or size (limit 64 MiB)")
136+ if fd < 0 || size < 1 || size > 16<<20 {
137+ return nil, errors.New("xkbcommon: invalid keymap FD or size (limit 16 MiB)")
139138 }
140139 var stat syscall.Stat_t
141140 if e := syscall.Fstat(fd, &stat); e != nil {
142141 return nil, fmt.Errorf("xkbcommon: stat keymap FD: %w", e)
143142 }
144- if stat.Mode&syscall.S_IFMT != syscall.S_IFREG || stat.Size < int64(size) {
145- return nil, errors.New("xkbcommon: keymap FD is not a regular file of the supplied size")
143+ if stat.Mode&syscall.S_IFMT != syscall.S_IFREG {
144+ return nil, errors.New("xkbcommon: keymap FD is not a regular file")
146145 }
147- b, e := syscall.Mmap(fd, 0, size, syscall.PROT_READ, syscall.MAP_PRIVATE)
148- if e != nil {
149- return nil, fmt.Errorf("xkbcommon: mmap keymap: %w", e)
146+ b := make([]byte, size)
147+ for offset := 0; offset < size; {
148+ n, e := syscall.Pread(fd, b[offset:], int64(offset))
149+ offset += n
150+ if e == syscall.EINTR {
151+ continue
152+ }
153+ if e != nil {
154+ return nil, fmt.Errorf("xkbcommon: read keymap FD: %w", e)
155+ }
156+ if n == 0 {
157+ return nil, fmt.Errorf("xkbcommon: read keymap FD: %w", errors.New("short read"))
158+ }
150159 }
151- defer syscall.Munmap(b)
152- if b[len(b)-1] != 0 {
153- return nil, errors.New("xkbcommon: Wayland keymap not NUL terminated")
160+ if b[len(b)-1] == 0 {
161+ b = b[:len(b)-1]
154162 }
155163 return newKeymap(c, func(h uintptr) uintptr {
156- v := loader.f.Xkb_keymap_new_from_buffer(h, ptr(b), uintptr(size-1), raw.XKB_KEYMAP_FORMAT_TEXT_V1, 0)
164+ v := loader.f.Xkb_keymap_new_from_buffer(h, ptr(b), uintptr(len(b)), raw.XKB_KEYMAP_FORMAT_TEXT_V1, 0)
157165 runtime.KeepAlive(b)
158166 return v
159167 })
@@ -129,31 +129,39 @@ func (c *Context) NewKeymapRules(rules, model, layout, variant, options string)
129 })129 })
130 }130 }
131 131
132-// NewKeymapFD maps exactly size bytes read-only and privately, then compiles the132+// NewKeymapFD copies exactly size bytes from offset zero of a Wayland xkb_v1
133-// Wayland xkb_v1 format. The caller retains ownership of fd, including on error;133+// keymap FD (at most 16 MiB). A trailing Wayland NUL, if present, is excluded
134-// this function never closes it. The mapping is removed before return. size must134+// from the compiled text. The caller retains ownership of fd, even on error.
135-// include the terminating NUL required by the Wayland protocol.
136 func (c *Context) NewKeymapFD(fd int, size int) (*Keymap, error) {135 func (c *Context) NewKeymapFD(fd int, size int) (*Keymap, error) {
137- if fd < 0 || size < 1 || size > 64<<20 {136+ if fd < 0 || size < 1 || size > 16<<20 {
138- return nil, errors.New("xkbcommon: invalid keymap FD or size (limit 64 MiB)")137+ return nil, errors.New("xkbcommon: invalid keymap FD or size (limit 16 MiB)")
139 }138 }
140 var stat syscall.Stat_t139 var stat syscall.Stat_t
141 if e := syscall.Fstat(fd, &stat); e != nil {140 if e := syscall.Fstat(fd, &stat); e != nil {
142 return nil, fmt.Errorf("xkbcommon: stat keymap FD: %w", e)141 return nil, fmt.Errorf("xkbcommon: stat keymap FD: %w", e)
143 }142 }
144- if stat.Mode&syscall.S_IFMT != syscall.S_IFREG || stat.Size < int64(size) {143+ if stat.Mode&syscall.S_IFMT != syscall.S_IFREG {
145- return nil, errors.New("xkbcommon: keymap FD is not a regular file of the supplied size")144+ return nil, errors.New("xkbcommon: keymap FD is not a regular file")
146 }145 }
147- b, e := syscall.Mmap(fd, 0, size, syscall.PROT_READ, syscall.MAP_PRIVATE)146+ b := make([]byte, size)
148- if e != nil {147+ for offset := 0; offset < size; {
149- return nil, fmt.Errorf("xkbcommon: mmap keymap: %w", e)148+ n, e := syscall.Pread(fd, b[offset:], int64(offset))
149+ offset += n
150+ if e == syscall.EINTR {
151+ continue
152+ }
153+ if e != nil {
154+ return nil, fmt.Errorf("xkbcommon: read keymap FD: %w", e)
155+ }
156+ if n == 0 {
157+ return nil, fmt.Errorf("xkbcommon: read keymap FD: %w", errors.New("short read"))
158+ }
150 }159 }
151- defer syscall.Munmap(b)160+ if b[len(b)-1] == 0 {
152- if b[len(b)-1] != 0 {161+ b = b[:len(b)-1]
153- return nil, errors.New("xkbcommon: Wayland keymap not NUL terminated")
154 }162 }
155 return newKeymap(c, func(h uintptr) uintptr {163 return newKeymap(c, func(h uintptr) uintptr {
156- v := loader.f.Xkb_keymap_new_from_buffer(h, ptr(b), uintptr(size-1), raw.XKB_KEYMAP_FORMAT_TEXT_V1, 0)164+ v := loader.f.Xkb_keymap_new_from_buffer(h, ptr(b), uintptr(len(b)), raw.XKB_KEYMAP_FORMAT_TEXT_V1, 0)
157 runtime.KeepAlive(b)165 runtime.KeepAlive(b)
158 return v166 return v
159 })167 })
M xkbcommon_test.go +172 -13
@@ -2,8 +2,10 @@ package xkbcommon
22
33 import (
44 "errors"
5+ "fmt"
56 "os"
67 "os/exec"
8+ "regexp"
79 "strings"
810 "testing"
911 "unsafe"
@@ -89,32 +91,72 @@ func TestMalformedAndLifetime(t *testing.T) {
8991 }
9092 func TestFD(t *testing.T) {
9193 c := live(t)
94+ short := keymapFile(t, []byte("short"))
95+ if _, e := c.NewKeymapFD(int(short.Fd()), 4096); e == nil || !strings.Contains(e.Error(), "short read") {
96+ t.Fatalf("truncated keymap: %v", e)
97+ }
98+ if _, e := short.Stat(); e != nil {
99+ t.Fatalf("FD was closed on error: %v", e)
100+ }
101+ for _, size := range []int{0, 16<<20 + 1} {
102+ if _, e := c.NewKeymapFD(int(short.Fd()), size); e == nil {
103+ t.Fatalf("accepted size %d", size)
104+ }
105+ }
106+ if _, e := c.NewKeymapFD(-1, 5); e == nil {
107+ t.Fatal("negative FD")
108+ }
109+ if _, e := exec.LookPath("xkbcli"); e != nil {
110+ t.Skipf("xkbcli compile-keymap reference unavailable: %v", e)
111+ }
92112 out, e := exec.Command("xkbcli", "compile-keymap", "--rules", "evdev", "--model", "pc105", "--layout", "fr", "--output-format", "1").Output()
93113 if e != nil {
94114 t.Fatalf("reference xkbcli: %v", e)
95115 }
96- f, e := os.CreateTemp(t.TempDir(), "keymap")
116+ f := keymapFile(t, append(out, 0))
117+ other, e := c.NewKeymapFD(int(f.Fd()), len(out)+1)
97118 if e != nil {
98119 t.Fatal(e)
99120 }
100- defer f.Close()
101- data := append(out, 0)
102- if _, e = f.Write(data); e != nil {
103- t.Fatal(e)
121+ other.Close()
122+ if _, e = f.Stat(); e != nil {
123+ t.Fatalf("FD was closed: %v", e)
124+ }
125+ if _, e = c.NewKeymapFD(int(f.Fd()), len(out)+2); e == nil || !strings.Contains(e.Error(), "short read") {
126+ t.Fatalf("truncated keymap: %v", e)
104127 }
105- other, e := c.NewKeymapFD(int(f.Fd()), len(data))
128+ if _, e = c.NewKeymapFD(int(f.Fd()), len(out)); e != nil {
129+ t.Fatalf("non-NUL text: %v", e)
130+ }
131+}
132+
133+func keymapFile(t *testing.T, data []byte) *os.File {
134+ t.Helper()
135+ f, e := os.CreateTemp(t.TempDir(), "keymap")
106136 if e != nil {
107137 t.Fatal(e)
108138 }
109- other.Close()
110- if _, e = f.Stat(); e != nil {
111- t.Fatalf("FD was closed: %v", e)
139+ t.Cleanup(func() { f.Close() })
140+ if _, e = f.Write(data); e != nil {
141+ t.Fatal(e)
112142 }
113- if _, e = c.NewKeymapFD(int(f.Fd()), len(data)-1); e == nil {
114- t.Fatal("missing NUL accepted")
143+ return f
144+}
145+
146+func TestLoaderDiagnostics(t *testing.T) {
147+ _, e := resolveLibrary("/no/such/libxkbcommon.so", raw.Symbols)
148+ if e == nil || !strings.Contains(e.Error(), "load /no/such/libxkbcommon.so") {
149+ t.Fatalf("missing library: %v", e)
150+ }
151+ if e := Available(); e != nil {
152+ if strings.Contains(e.Error(), "load libxkbcommon.so.0") {
153+ t.Skipf("missing symbol test needs libxkbcommon.so.0: %v", e)
154+ }
155+ t.Fatal(e)
115156 }
116- if _, e = c.NewKeymapFD(-1, len(data)); e == nil {
117- t.Fatal("negative FD")
157+ _, e = resolveLibrary("libxkbcommon.so.0", []string{"xkb_nonexistent_test_symbol"})
158+ if e == nil || !strings.Contains(e.Error(), "missing xkb_nonexistent_test_symbol") {
159+ t.Fatalf("missing symbol: %v", e)
118160 }
119161 }
120162 func TestLayoutsAndDifferential(t *testing.T) {
@@ -191,6 +233,123 @@ func TestLayoutsAndDifferential(t *testing.T) {
191233 t.Fatalf("mods %#x", mods)
192234 }
193235 }
236+
237+// These expectations are fixed evdev/XKB facts, not values computed by the
238+// binding under test. Every case compiles through the Wayland FD path.
239+func TestFDReferenceKeyEvents(t *testing.T) {
240+ c := live(t)
241+ if _, e := exec.LookPath("xkbcli"); e != nil {
242+ t.Logf("xkbcli compile-keymap/how-to-type comparison skipped: %v", e)
243+ }
244+ for _, tc := range []struct {
245+ layout, key, utf8, modifiers string
246+ evdev, group, sym uint32
247+ }{
248+ {"us", "q", "q", "", 16, 0, raw.XKB_KEY_q},
249+ {"us", "Q", "Q", "Shift", 16, 0, raw.XKB_KEY_Q},
250+ {"us", "Q", "Q", "Lock", 16, 0, raw.XKB_KEY_Q},
251+ {"fr", "a", "a", "", 16, 0, raw.XKB_KEY_a},
252+ {"fr", "€", "€", "Mod5", 18, 0, raw.XKB_KEY_EuroSign},
253+ {"de", "z", "z", "", 21, 0, raw.XKB_KEY_z},
254+ {"de", "@", "@", "Mod5", 16, 0, raw.XKB_KEY_at},
255+ {"de", "dead_circumflex", "", "", 41, 0, raw.XKB_KEY_dead_circumflex},
256+ {"us,fr", "a", "a", "", 16, 1, raw.XKB_KEY_a},
257+ } {
258+ t.Run(tc.layout+"/"+tc.key+"/"+tc.modifiers, func(t *testing.T) {
259+ // xkbcli independently compiles the text used by the Wayland FD path.
260+ // Without it, still check the fixed reference table against rules.
261+ var k *Keymap
262+ if _, e := exec.LookPath("xkbcli"); e == nil {
263+ text, e := exec.Command("xkbcli", "compile-keymap", "--layout", tc.layout, "--output-format", "1").Output()
264+ if e != nil {
265+ t.Fatal(e)
266+ }
267+ f := keymapFile(t, append(text, 0))
268+ k, e = c.NewKeymapFD(int(f.Fd()), len(text)+1)
269+ if e != nil {
270+ t.Fatal(e)
271+ }
272+ t.Cleanup(func() { k.Close() })
273+ } else {
274+ k = rules(t, c, tc.layout)
275+ }
276+ s := state(t, k)
277+ var mask uint32
278+ if tc.modifiers != "" {
279+ idx, e := k.ModIndex(tc.modifiers)
280+ if e != nil || idx >= 32 {
281+ t.Fatalf("modifier %s: %d %v", tc.modifiers, idx, e)
282+ }
283+ mask = 1 << idx
284+ }
285+ var depressed, locked uint32
286+ if tc.modifiers == "Lock" {
287+ locked = mask
288+ } else {
289+ depressed = mask
290+ }
291+ if _, e := s.UpdateMask(depressed, 0, locked, 0, 0, tc.group); e != nil {
292+ t.Fatal(e)
293+ }
294+ if group, e := s.Layout(); e != nil || group != tc.group {
295+ t.Fatalf("group %d: %v", group, e)
296+ }
297+ code := WaylandKeycode(tc.evdev)
298+ if sym, e := s.KeySym(code); e != nil || sym != tc.sym {
299+ t.Fatalf("keysym %#x want %#x: %v", sym, tc.sym, e)
300+ }
301+ if v, e := s.UTF8(code); e != nil || v != tc.utf8 {
302+ t.Fatalf("utf8 %q want %q: %v", v, tc.utf8, e)
303+ }
304+ if tc.sym == raw.XKB_KEY_dead_circumflex {
305+ if v, e := s.UTF8(WaylandKeycode(18)); e != nil || v != "e" {
306+ t.Fatalf("compose input e: %q %v", v, e)
307+ }
308+ table, e := c.NewComposeTable("en_US.UTF-8")
309+ if e != nil {
310+ t.Fatal(e)
311+ }
312+ t.Cleanup(func() { table.Close() })
313+ compose, e := table.NewState()
314+ if e != nil {
315+ t.Fatal(e)
316+ }
317+ t.Cleanup(func() { compose.Close() })
318+ if _, e := compose.Feed(tc.sym); e != nil {
319+ t.Fatal(e)
320+ }
321+ if _, e := compose.Feed(raw.XKB_KEY_e); e != nil {
322+ t.Fatal(e)
323+ }
324+ if status, e := compose.Status(); e != nil || status != raw.XKB_COMPOSE_COMPOSED {
325+ t.Fatalf("compose status %d: %v", status, e)
326+ }
327+ if sym, e := compose.KeySym(); e != nil || sym != raw.XKB_KEY_ecircumflex {
328+ t.Fatalf("compose sym %#x: %v", sym, e)
329+ }
330+ if v, e := compose.UTF8(); e != nil || v != "ê" {
331+ t.Fatalf("compose utf8 %q: %v", v, e)
332+ }
333+ }
334+ if _, e := exec.LookPath("xkbcli"); e == nil && tc.group == 0 && tc.modifiers != "Lock" {
335+ args := []string{"how-to-type", "--layout", tc.layout}
336+ if tc.sym == raw.XKB_KEY_dead_circumflex {
337+ args = append(args, "--keysym")
338+ }
339+ out, e := exec.Command("xkbcli", append(args, tc.key)...).Output()
340+ if e != nil {
341+ t.Fatal(e)
342+ }
343+ // The CLI reports XKB keycodes, not evdev codes.
344+ pattern := regexp.MustCompile(`(?m)^\s*` + fmt.Sprint(code) + `\s+\S+\s+\d+\s+.*\[.*\]`)
345+ if !pattern.Match(out) {
346+ t.Fatalf("xkbcli how-to-type lacks keycode %d: %s", code, out)
347+ }
348+ }
349+ })
350+ }
351+}
352+
194353 func TestCompose(t *testing.T) {
195354 c := live(t)
196355 table, e := c.NewComposeTable("en_US.UTF-8")
@@ -2,8 +2,10 @@ package xkbcommon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "fmt"
5 "os"6 "os"
6 "os/exec"7 "os/exec"
8+ "regexp"
7 "strings"9 "strings"
8 "testing"10 "testing"
9 "unsafe"11 "unsafe"
@@ -89,32 +91,72 @@ func TestMalformedAndLifetime(t *testing.T) {
89 }91 }
90 func TestFD(t *testing.T) {92 func TestFD(t *testing.T) {
91 c := live(t)93 c := live(t)
94+ short := keymapFile(t, []byte("short"))
95+ if _, e := c.NewKeymapFD(int(short.Fd()), 4096); e == nil || !strings.Contains(e.Error(), "short read") {
96+ t.Fatalf("truncated keymap: %v", e)
97+ }
98+ if _, e := short.Stat(); e != nil {
99+ t.Fatalf("FD was closed on error: %v", e)
100+ }
101+ for _, size := range []int{0, 16<<20 + 1} {
102+ if _, e := c.NewKeymapFD(int(short.Fd()), size); e == nil {
103+ t.Fatalf("accepted size %d", size)
104+ }
105+ }
106+ if _, e := c.NewKeymapFD(-1, 5); e == nil {
107+ t.Fatal("negative FD")
108+ }
109+ if _, e := exec.LookPath("xkbcli"); e != nil {
110+ t.Skipf("xkbcli compile-keymap reference unavailable: %v", e)
111+ }
92 out, e := exec.Command("xkbcli", "compile-keymap", "--rules", "evdev", "--model", "pc105", "--layout", "fr", "--output-format", "1").Output()112 out, e := exec.Command("xkbcli", "compile-keymap", "--rules", "evdev", "--model", "pc105", "--layout", "fr", "--output-format", "1").Output()
93 if e != nil {113 if e != nil {
94 t.Fatalf("reference xkbcli: %v", e)114 t.Fatalf("reference xkbcli: %v", e)
95 }115 }
96- f, e := os.CreateTemp(t.TempDir(), "keymap")116+ f := keymapFile(t, append(out, 0))
117+ other, e := c.NewKeymapFD(int(f.Fd()), len(out)+1)
97 if e != nil {118 if e != nil {
98 t.Fatal(e)119 t.Fatal(e)
99 }120 }
100- defer f.Close()121+ other.Close()
101- data := append(out, 0)122+ if _, e = f.Stat(); e != nil {
102- if _, e = f.Write(data); e != nil {123+ t.Fatalf("FD was closed: %v", e)
103- t.Fatal(e)124+ }
125+ if _, e = c.NewKeymapFD(int(f.Fd()), len(out)+2); e == nil || !strings.Contains(e.Error(), "short read") {
126+ t.Fatalf("truncated keymap: %v", e)
104 }127 }
105- other, e := c.NewKeymapFD(int(f.Fd()), len(data))128+ if _, e = c.NewKeymapFD(int(f.Fd()), len(out)); e != nil {
129+ t.Fatalf("non-NUL text: %v", e)
130+ }
131+}
132+
133+func keymapFile(t *testing.T, data []byte) *os.File {
134+ t.Helper()
135+ f, e := os.CreateTemp(t.TempDir(), "keymap")
106 if e != nil {136 if e != nil {
107 t.Fatal(e)137 t.Fatal(e)
108 }138 }
109- other.Close()139+ t.Cleanup(func() { f.Close() })
110- if _, e = f.Stat(); e != nil {140+ if _, e = f.Write(data); e != nil {
111- t.Fatalf("FD was closed: %v", e)141+ t.Fatal(e)
112 }142 }
113- if _, e = c.NewKeymapFD(int(f.Fd()), len(data)-1); e == nil {143+ return f
114- t.Fatal("missing NUL accepted")144+}
145+
146+func TestLoaderDiagnostics(t *testing.T) {
147+ _, e := resolveLibrary("/no/such/libxkbcommon.so", raw.Symbols)
148+ if e == nil || !strings.Contains(e.Error(), "load /no/such/libxkbcommon.so") {
149+ t.Fatalf("missing library: %v", e)
150+ }
151+ if e := Available(); e != nil {
152+ if strings.Contains(e.Error(), "load libxkbcommon.so.0") {
153+ t.Skipf("missing symbol test needs libxkbcommon.so.0: %v", e)
154+ }
155+ t.Fatal(e)
115 }156 }
116- if _, e = c.NewKeymapFD(-1, len(data)); e == nil {157+ _, e = resolveLibrary("libxkbcommon.so.0", []string{"xkb_nonexistent_test_symbol"})
117- t.Fatal("negative FD")158+ if e == nil || !strings.Contains(e.Error(), "missing xkb_nonexistent_test_symbol") {
159+ t.Fatalf("missing symbol: %v", e)
118 }160 }
119 }161 }
120 func TestLayoutsAndDifferential(t *testing.T) {162 func TestLayoutsAndDifferential(t *testing.T) {
@@ -191,6 +233,123 @@ func TestLayoutsAndDifferential(t *testing.T) {
191 t.Fatalf("mods %#x", mods)233 t.Fatalf("mods %#x", mods)
192 }234 }
193 }235 }
236+
237+// These expectations are fixed evdev/XKB facts, not values computed by the
238+// binding under test. Every case compiles through the Wayland FD path.
239+func TestFDReferenceKeyEvents(t *testing.T) {
240+ c := live(t)
241+ if _, e := exec.LookPath("xkbcli"); e != nil {
242+ t.Logf("xkbcli compile-keymap/how-to-type comparison skipped: %v", e)
243+ }
244+ for _, tc := range []struct {
245+ layout, key, utf8, modifiers string
246+ evdev, group, sym uint32
247+ }{
248+ {"us", "q", "q", "", 16, 0, raw.XKB_KEY_q},
249+ {"us", "Q", "Q", "Shift", 16, 0, raw.XKB_KEY_Q},
250+ {"us", "Q", "Q", "Lock", 16, 0, raw.XKB_KEY_Q},
251+ {"fr", "a", "a", "", 16, 0, raw.XKB_KEY_a},
252+ {"fr", "€", "€", "Mod5", 18, 0, raw.XKB_KEY_EuroSign},
253+ {"de", "z", "z", "", 21, 0, raw.XKB_KEY_z},
254+ {"de", "@", "@", "Mod5", 16, 0, raw.XKB_KEY_at},
255+ {"de", "dead_circumflex", "", "", 41, 0, raw.XKB_KEY_dead_circumflex},
256+ {"us,fr", "a", "a", "", 16, 1, raw.XKB_KEY_a},
257+ } {
258+ t.Run(tc.layout+"/"+tc.key+"/"+tc.modifiers, func(t *testing.T) {
259+ // xkbcli independently compiles the text used by the Wayland FD path.
260+ // Without it, still check the fixed reference table against rules.
261+ var k *Keymap
262+ if _, e := exec.LookPath("xkbcli"); e == nil {
263+ text, e := exec.Command("xkbcli", "compile-keymap", "--layout", tc.layout, "--output-format", "1").Output()
264+ if e != nil {
265+ t.Fatal(e)
266+ }
267+ f := keymapFile(t, append(text, 0))
268+ k, e = c.NewKeymapFD(int(f.Fd()), len(text)+1)
269+ if e != nil {
270+ t.Fatal(e)
271+ }
272+ t.Cleanup(func() { k.Close() })
273+ } else {
274+ k = rules(t, c, tc.layout)
275+ }
276+ s := state(t, k)
277+ var mask uint32
278+ if tc.modifiers != "" {
279+ idx, e := k.ModIndex(tc.modifiers)
280+ if e != nil || idx >= 32 {
281+ t.Fatalf("modifier %s: %d %v", tc.modifiers, idx, e)
282+ }
283+ mask = 1 << idx
284+ }
285+ var depressed, locked uint32
286+ if tc.modifiers == "Lock" {
287+ locked = mask
288+ } else {
289+ depressed = mask
290+ }
291+ if _, e := s.UpdateMask(depressed, 0, locked, 0, 0, tc.group); e != nil {
292+ t.Fatal(e)
293+ }
294+ if group, e := s.Layout(); e != nil || group != tc.group {
295+ t.Fatalf("group %d: %v", group, e)
296+ }
297+ code := WaylandKeycode(tc.evdev)
298+ if sym, e := s.KeySym(code); e != nil || sym != tc.sym {
299+ t.Fatalf("keysym %#x want %#x: %v", sym, tc.sym, e)
300+ }
301+ if v, e := s.UTF8(code); e != nil || v != tc.utf8 {
302+ t.Fatalf("utf8 %q want %q: %v", v, tc.utf8, e)
303+ }
304+ if tc.sym == raw.XKB_KEY_dead_circumflex {
305+ if v, e := s.UTF8(WaylandKeycode(18)); e != nil || v != "e" {
306+ t.Fatalf("compose input e: %q %v", v, e)
307+ }
308+ table, e := c.NewComposeTable("en_US.UTF-8")
309+ if e != nil {
310+ t.Fatal(e)
311+ }
312+ t.Cleanup(func() { table.Close() })
313+ compose, e := table.NewState()
314+ if e != nil {
315+ t.Fatal(e)
316+ }
317+ t.Cleanup(func() { compose.Close() })
318+ if _, e := compose.Feed(tc.sym); e != nil {
319+ t.Fatal(e)
320+ }
321+ if _, e := compose.Feed(raw.XKB_KEY_e); e != nil {
322+ t.Fatal(e)
323+ }
324+ if status, e := compose.Status(); e != nil || status != raw.XKB_COMPOSE_COMPOSED {
325+ t.Fatalf("compose status %d: %v", status, e)
326+ }
327+ if sym, e := compose.KeySym(); e != nil || sym != raw.XKB_KEY_ecircumflex {
328+ t.Fatalf("compose sym %#x: %v", sym, e)
329+ }
330+ if v, e := compose.UTF8(); e != nil || v != "ê" {
331+ t.Fatalf("compose utf8 %q: %v", v, e)
332+ }
333+ }
334+ if _, e := exec.LookPath("xkbcli"); e == nil && tc.group == 0 && tc.modifiers != "Lock" {
335+ args := []string{"how-to-type", "--layout", tc.layout}
336+ if tc.sym == raw.XKB_KEY_dead_circumflex {
337+ args = append(args, "--keysym")
338+ }
339+ out, e := exec.Command("xkbcli", append(args, tc.key)...).Output()
340+ if e != nil {
341+ t.Fatal(e)
342+ }
343+ // The CLI reports XKB keycodes, not evdev codes.
344+ pattern := regexp.MustCompile(`(?m)^\s*` + fmt.Sprint(code) + `\s+\S+\s+\d+\s+.*\[.*\]`)
345+ if !pattern.Match(out) {
346+ t.Fatalf("xkbcli how-to-type lacks keycode %d: %s", code, out)
347+ }
348+ }
349+ })
350+ }
351+}
352+
194 func TestCompose(t *testing.T) {353 func TestCompose(t *testing.T) {
195 c := live(t)354 c := live(t)
196 table, e := c.NewComposeTable("en_US.UTF-8")355 table, e := c.NewComposeTable("en_US.UTF-8")

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 1

Tags 2

↑↓ moveenter openesc close