sensitive_test.go RawHistoryFolder

1package wayland
2
3import (
4	"sync/atomic"
5	"testing"
6	"time"
7
8	"github.com/bnema/go-wayland-bindings/server/cursorshape"
9	wlr "github.com/bnema/go-wayland-bindings/server/wlrforeigntoplevelmanagement"
10	screencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"
11	"github.com/bnema/go-wayland-bindings/server/xdgactivation"
12	"github.com/bnema/go-wayland-bindings/server/xdgshell"
13	"github.com/bnema/neferwl/internal/adapters/sessionsecurity"
14	"github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
15
16	source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
17	ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
18	"github.com/bnema/go-wayland-bindings/server/inputmethod"
19	"github.com/bnema/go-wayland-bindings/server/textinput"
20	"github.com/bnema/go-wayland-bindings/server/virtualkeyboard"
21	"github.com/bnema/go-wayland-bindings/server/wayland"
22	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
23	"github.com/bnema/neferwl/internal/ports"
24	"golang.org/x/sys/unix"
25)
26
27// installSecurity uses the canonical generated mock, with coherent atomic
28// snapshots; transitions are serialized through the real DISPLAY loop.
29func installSecurity(t *testing.T, s *Server) *atomic.Pointer[ports.SecurityState] {
30	t.Helper()
31	state := &atomic.Pointer[ports.SecurityState]{}
32	state.Store(&ports.SecurityState{})
33	security := portsmocks.NewMockSessionSecurity(t)
34	security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *state.Load() }).Maybe()
35	if !s.display.Do(func() { s.security = security }) {
36		t.Fatal("display stopped")
37	}
38	return state
39}
40
41func protect(t *testing.T, s *Server, state *atomic.Pointer[ports.SecurityState], generation ports.LockGeneration, protected bool) {
42	t.Helper()
43	if !s.display.Do(func() { state.Store(&ports.SecurityState{Generation: generation, Protected: protected}) }) {
44		t.Fatal("display stopped")
45	}
46}
47
48func TestProtectedClipboardPreexistingOffer(t *testing.T) {
49	s, events, commands, dir := keyboardServer(t)
50	state := installSecurity(t, s)
51	a := newClipClient(t, s, dir, events)
52	a.focus(t, commands)
53	src := a.copy(t)
54	offer, ok := a.selection(t)
55	if !ok || offer == 0 {
56		t.Fatal("no unlocked offer")
57	}
58	protect(t, s, state, 1, true)
59	var fds [2]int
60	if err := unix.Pipe2(fds[:], unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
61		t.Fatal(err)
62	}
63	defer unix.Close(fds[0])
64	if err := wireRequest(a.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil {
65		t.Fatal(err)
66	}
67	unix.Close(fds[1])
68	roundtrip(t, a.c)
69	select {
70	case <-src.sends:
71		t.Fatal("protected receive handed pipe to source")
72	default:
73	}
74	var b [1]byte
75	n, err := unix.Read(fds[0], b[:])
76	if err != nil || n != 0 {
77		t.Fatalf("receive pipe not closed: n=%d err=%v", n, err)
78	}
79	rejected := a.copy(t)
80	select {
81	case <-rejected.cancelled:
82	default:
83		t.Fatal("protected selection not rejected")
84	}
85	if id, ok := a.selection(t); ok {
86		t.Fatalf("protected offer announced %d", id)
87	}
88	protect(t, s, state, 2, false)
89	// Existing clipboard data remains usable when unlocked.
90	a.copy(t)
91	if id, ok := a.selection(t); !ok || id == 0 {
92		t.Fatal("unlocked selection not restored")
93	}
94}
95
96func TestProtectedCaptureAdmission(t *testing.T) {
97	h := newAdmissionHarness(t, 4)
98	state := installSecurity(t, h.s)
99	a := h.client(t)
100	// Create the ext session before protection, then test its late frame.
101	p := a.captureExt(t)
102	req, ok := h.accepted(t, p)
103	if !ok {
104		t.Fatal("unlocked ext capture rejected")
105	}
106	a.destroy(t, p)
107	h.complete(t, req, 0)
108	protect(t, h.s, state, 1, true)
109	if _, ok := h.accepted(t, a.captureWlr(t)); ok {
110		t.Fatal("protected wlr capture admitted")
111	}
112	if _, ok := h.accepted(t, a.captureExt(t)); ok {
113		t.Fatal("protected ext capture admitted")
114	}
115	if h.inflight(t) != 0 {
116		t.Fatal("protected captures reserved credits")
117	}
118	var next uint64
119	if !h.s.display.Do(func() { next = h.s.nextCapture }) {
120		t.Fatal("display stopped")
121	}
122	if next != req.ID {
123		t.Fatalf("protected capture allocated ID: %d", next)
124	}
125	// A newly created ext session is stopped and never admits capture.
126	b := h.client(t)
127	sourceManager := bindProtocol(t, b.c, "ext_output_image_capture_source_manager_v1")
128	manager := bindProtocol(t, b.c, "ext_image_copy_capture_manager_v1")
129	src, session := b.c.AllocateID(), b.c.AllocateID()
130	registerProtocol(t, b.c, src)
131	requestProtocol(t, b.c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, b.output)
132	ev := &captureEvents{events: make(chan uint16, 16)}
133	ev.SetID(session)
134	registerWireProxy(b.c, ev)
135	requestProtocol(t, b.c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
136	roundtrip(t, b.c)
137	select {
138	case op := <-ev.events:
139		if op != uint16(ext.ExtImageCopyCaptureSessionV1EventStopped) {
140			t.Fatalf("new session event %d", op)
141		}
142	default:
143		t.Fatal("new protected ext session not stopped")
144	}
145	if len(ev.events) != 0 {
146		t.Fatal("protected session disclosed constraints")
147	}
148	b.session = session
149	if _, ok := h.accepted(t, b.captureExt(t)); ok {
150		t.Fatal("new protected session admitted")
151	}
152	protect(t, h.s, state, 2, false)
153	req, ok = h.accepted(t, a.captureWlr(t))
154	if !ok {
155		t.Fatal("unlocked wlr capture rejected")
156	}
157	h.complete(t, req, 0)
158}
159
160func TestProtectedVirtualKeyboard(t *testing.T) {
161	for _, destroyProtected := range []bool{true, false} {
162		t.Run(map[bool]string{true: "protected-destroy", false: "post-unlock-destroy"}[destroyProtected], func(t *testing.T) {
163			s, events, commands, dir := keyboardServer(t)
164			state := installSecurity(t, s)
165			target, log := focusedTarget(t, s, events, commands, dir)
166			typer, kb := virtualTyper(t, s, dir)
167			sendVirtualKeymap(t, typer, kb, keymapText(t))
168			requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1))
169			roundtrip(t, typer, target)
170			if len(log.opcodes) == 0 {
171				t.Fatal("unlocked virtual key missing")
172			}
173			log.opcodes = nil
174			protect(t, s, state, 1, true)
175			requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(2), uint32(31), uint32(1))
176			requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestModifiers, uint32(1), uint32(0), uint32(0), uint32(0))
177			roundtrip(t, typer, target)
178			if len(log.opcodes) != 0 {
179				t.Fatalf("protected virtual events: %v", log.opcodes)
180			}
181			if !destroyProtected {
182				protect(t, s, state, 2, false)
183			}
184			requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestDestroy)
185			roundtrip(t, typer, target)
186			if len(log.opcodes) != 0 {
187				t.Fatalf("old virtual held state injected on destroy: %v", log.opcodes)
188			}
189		})
190	}
191}
192
193func TestProtectedInputMethod(t *testing.T) {
194	s, events, commands, dir := keyboardServer(t)
195	state := installSecurity(t, s)
196	ime := newIMEApp(t, s, dir)
197	app := newTextApp(t, s, events, commands, dir)
198	app.enable(t)
199	_, grab := ime.grab(t)
200	settle(t, app.c, ime.c)
201	app.log.take()
202	ime.log.take()
203	grab.take()
204	protect(t, s, state, 1, true)
205	app.request(t, textinput.ZwpTextInputV3RequestSetSurroundingText, "locker-secret", int32(13), int32(13))
206	app.request(t, textinput.ZwpTextInputV3RequestCommit)
207	ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "injected")
208	ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(1))
209	// Exercise the live grab's delivery entrypoint; normal seat routing/reset is
210	// deliberately outside this adapter assignment.
211	if !s.display.Do(func() {
212		s.grabKey(ports.ForwardKey{ID: app.window, Key: ports.KeyEvent{Keycode: 30, Pressed: true, State: ports.ModState{Depressed: 1}}})
213		s.sendGrabKeymap(true)
214	}) {
215		t.Fatal("display stopped")
216	}
217	_, late := ime.grab(t)
218	settle(t, app.c, ime.c)
219	expectEvents(t, "protected IM state", ime.log.take())
220	expectEvents(t, "protected IM injection", app.log.take())
221	expectEvents(t, "existing protected grab", grab.take())
222	expectEvents(t, "new protected grab", late.take())
223	protect(t, s, state, 2, false)
224	app.enable(t)
225	settle(t, app.c, ime.c)
226	if len(ime.log.take()) == 0 {
227		t.Fatal("unlocked IM state not delivered")
228	}
229}
230
231// While the session is protected nothing of it is captured: ext sessions stop
232// or fail their frames, wlr-screencopy fails, a new session is stopped, an
233// exclusion attach is refused, and none of it reaches core or the renderer.
234func TestProtectedCapture(t *testing.T) {
235	h := newCaptureHarness(t)
236	state := installSecurity(t, h.s)
237	owner, session, open, _, token := h.exclusionOwner(t)
238	h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 1})
239	h.settle(t, owner)
240	// Unprotected, the same frame is admitted (positive control).
241	owner.extFrame(t, session, 4, 4)
242	accepted := receiveCapture(t, h.captures)
243	if !accepted.Exclude || accepted.Session != open.ID {
244		t.Fatalf("owner capture not excluded: %+v", accepted)
245	}
246	h.captured <- ports.CaptureDone{ID: accepted.ID, Output: accepted.Output, Time: time.Now()}
247	hd := h.hud(t, ports.LayerTop, 0)
248	protect(t, h.s, state, 1, true)
249	if !eventsInclude(owner.extFrame(t, session, 4, 4), frameFailed) {
250		t.Fatal("protected ext frame not failed")
251	}
252	if !eventsInclude(owner.wlrFrame(t), uint16(screencopy.ZwlrScreencopyFrameV1EventFailed)) {
253		t.Fatal("protected wlr frame not failed")
254	}
255	noCapture(t, h.captures)
256	att := hd.attach(t, token)
257	expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureUnauthorized))
258	// A session made while protected is stopped, for every kind of source.
259	other := h.client(t)
260	for name, src := range map[string]uint32{"output": other.outputSource(t), "region": other.regionSource(t, 0, 0, 2, 2)} {
261		_, ev := other.session(t, src)
262		if got := nextSession(t, other, ev); got[0] != evStopped {
263			t.Fatalf("%s session while protected: %v", name, got)
264		}
265	}
266	for len(h.events) > 0 {
267		switch ev := (<-h.events).(type) {
268		case ports.CaptureSessionOpen, ports.CaptureExclusionBegin, ports.CaptureExclusionLayer:
269			t.Fatalf("protected capture request reached core: %+v", ev)
270		}
271	}
272	// Unlocked again, capture works for a new session.
273	protect(t, h.s, state, 2, false)
274	cc := h.client(t)
275	again, ev := cc.session(t, cc.outputSource(t))
276	constraints(t, cc, ev)
277	h.open(t)
278	cc.extFrame(t, again, 4, 4)
279	receiveCapture(t, h.captures)
280}
281
282// Engaging the lock ends every capture session and its exclusion and tells
283// core, so a session never outlives the lock and no hidden-workspace or
284// region session keeps rendering.
285func TestSessionLockEndsCaptureSessions(t *testing.T) {
286	gate := &sessionsecurity.Gate{}
287	h := newCaptureHarnessWith(t, func(o *Options, c *Channels) {
288		o.Security = gate
289		c.SecurityChanges = make(chan ports.SecurityState, 1)
290		c.SecurityEvents = make(chan ports.SecurityBackendEvent, 16)
291	})
292	owner, session, open, msgs, _ := h.exclusionOwner(t)
293	region, rev := owner.session(t, owner.regionSource(t, 0, 0, 2, 2))
294	constraints(t, owner, rev)
295	regionOpen := h.open(t)
296	manager := bindLockManager(t, owner.c)
297	if _, err := manager.Lock(); err != nil {
298		t.Fatal(err)
299	}
300	if err := owner.c.Roundtrip(); err != nil {
301		t.Fatal(err)
302	}
303	if !gate.Snapshot().Protected {
304		t.Fatal("lock did not engage")
305	}
306	closed := map[uint64]bool{}
307	for len(closed) < 2 {
308		closed[nextEvent[ports.CaptureSessionClose](t, h.events).ID] = true
309	}
310	if !closed[open.ID] || !closed[regionOpen.ID] {
311		t.Fatalf("core not told: %v", closed)
312	}
313	// Both sessions are stopped and the exclusion failed with its session.
314	if !eventsInclude(owner.extFrame(t, session, 4, 4), frameFailed) || !eventsInclude(owner.extFrame(t, region, 2, 2), frameFailed) {
315		t.Fatal("frame of a locked session not failed")
316	}
317	noCapture(t, h.captures)
318	_ = msgs
319}
320
321func TestProtectedActivationAndForeignInventory(t *testing.T) {
322	s, events, commands, dir := lifecycleServer(t)
323	state := installSecurity(t, s)
324	c := protocolClient(t, s, dir)
325	w, surf, xdg := surfaceMapper(t, c, events)()
326	commands <- ports.FocusWindow{ID: w.ID}
327	waitFocus(t, s, w.ID)
328	manager := bindProtocol(t, c, "xdg_activation_v1")
329	token := newToken(t, c, manager)
330	foreign := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)
331	log := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
332	log.SetID(foreign)
333	registerWireProxy(c, log)
334	takeEvents(t, c, log.events)
335	handle := <-log.handles
336	seat := bindProtocol(t, c, "wl_seat")
337	registerProtocol(t, c, seat)
338	protect(t, s, state, 1, true)
339	requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
340	requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat)
341	requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetFullscreen, uint32(0))
342	requestProtocol(t, c, xdg+1, xdgshell.ToplevelRequestSetTitle, "sensitive-title")
343	noActivation(t, c, events)
344	if len(log.events) != 0 {
345		t.Fatal("foreign inventory updated while protected")
346	}
347	lateID := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)
348	late := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
349	late.SetID(lateID)
350	registerWireProxy(c, late)
351	roundtrip(t, c)
352	if len(late.handles) != 0 || len(late.events) != 0 {
353		t.Fatal("new foreign manager disclosed protected inventory")
354	}
355	protect(t, s, state, 2, false)
356	requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
357	noActivation(t, c, events) // the protected attempt consumed the token
358}
359
360func TestProtectedCursorChanges(t *testing.T) {
361	s, events, commands, cursors, dir := cursorServer(t)
362	state := installSecurity(t, s)
363	c := protocolClient(t, s, dir)
364	seat := bindProtocol(t, c, "wl_seat")
365	registerProtocol(t, c, seat)
366	pointer := c.AllocateID()
367	registerProtocol(t, c, pointer)
368	requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
369	manager := bindProtocol(t, c, "wp_cursor_shape_manager_v1")
370	device := c.AllocateID()
371	registerProtocol(t, c, device)
372	requestProtocol(t, c, manager, cursorshape.WpCursorShapeManagerV1RequestGetPointer, device, pointer)
373	w := toplevelMapper(t, c, events)()
374	commands <- ports.PointerFocus{ID: w.ID}
375	nextCursor(t, cursors)
376	protect(t, s, state, 1, true)
377	requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(1), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText))
378	requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), uint32(0), int32(0), int32(0))
379	roundtrip(t, c)
380	select {
381	case change := <-cursors:
382		t.Fatalf("normal cursor overrode protection: %+v", change)
383	case <-time.After(50 * time.Millisecond):
384	}
385}
386
387func TestProtectedVirtualKeyboardQuietEpoch(t *testing.T) {
388	s, events, commands, dir := keyboardServer(t)
389	state := installSecurity(t, s)
390	target, log := focusedTarget(t, s, events, commands, dir)
391	typer, kb := virtualTyper(t, s, dir)
392	sendVirtualKeymap(t, typer, kb, keymapText(t))
393	requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1))
394	roundtrip(t, typer, target)
395	log.opcodes = nil
396	protect(t, s, state, 1, true)
397	protect(t, s, state, 2, false)
398	// No requests during protection: generation must still invalidate held keys.
399	requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(2), uint32(30), uint32(0))
400	requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestDestroy)
401	roundtrip(t, typer, target)
402	if len(log.opcodes) != 0 {
403		t.Fatalf("old virtual state crossed quiet lock epoch: %v", log.opcodes)
404	}
405}
406
407func TestProtectedVirtualKeymapRequiresResend(t *testing.T) {
408	for _, resend := range []bool{false, true} {
409		t.Run(map[bool]string{false: "old-keymap-rejected", true: "fresh-keymap-accepted"}[resend], func(t *testing.T) {
410			s, events, commands, dir := keyboardServer(t)
411			state := installSecurity(t, s)
412			target, log := focusedTarget(t, s, events, commands, dir)
413			typer, kb := virtualTyper(t, s, dir)
414			sendVirtualKeymap(t, typer, kb, keymapText(t))
415			requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1))
416			roundtrip(t, typer, target)
417			log.opcodes = nil
418			var owner *virtualKeyboard
419			var oldFD int
420			if !s.display.Do(func() { owner = s.seat.keymapOwner; oldFD = owner.fd }) {
421				t.Fatal("display stopped")
422			}
423			protect(t, s, state, 1, true)
424			sendVirtualKeymap(t, typer, kb, keymapText(t))
425			roundtrip(t, typer, target)
426			var cleared bool
427			if !s.display.Do(func() { cleared = owner.fd == -1 && owner.text == "" && owner.size == 0 }) {
428				t.Fatal("display stopped")
429			}
430			if !cleared {
431				t.Fatal("protected keymap retained old layout")
432			}
433			if _, err := unix.FcntlInt(uintptr(oldFD), unix.F_GETFD, 0); err != unix.EBADF {
434				t.Fatalf("old keymap FD not closed: %v", err)
435			}
436			if len(log.opcodes) != 0 {
437				t.Fatalf("protected keymap events: %v", log.opcodes)
438			}
439			protect(t, s, state, 2, false)
440			if resend {
441				sendVirtualKeymap(t, typer, kb, keymapText(t))
442			}
443			requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(2), uint32(31), uint32(1))
444			if !resend {
445				expectProtocolError(t, typer, kb, uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap))
446				roundtrip(t, target)
447				if len(log.opcodes) != 0 {
448					t.Fatalf("old keymap injected after unlock: %v", log.opcodes)
449				}
450			} else {
451				roundtrip(t, typer, target)
452				found := false
453				for _, op := range log.opcodes {
454					if op == uint16(wayland.KeyboardEventKey) {
455						found = true
456					}
457				}
458				if !found {
459					t.Fatal("fresh post-unlock keymap cannot type")
460				}
461			}
462		})
463	}
464}
465
466func TestProtectedForeignCloseDeferredUntilRefresh(t *testing.T) {
467	s, events, _, dir := lifecycleServer(t)
468	state := installSecurity(t, s)
469	c := protocolClient(t, s, dir)
470	w, surf, _ := surfaceMapper(t, c, events)()
471	foreign := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)
472	log := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
473	log.SetID(foreign)
474	registerWireProxy(c, log)
475	takeEvents(t, c, log.events)
476	<-log.handles
477	protect(t, s, state, 1, true)
478	requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0))
479	requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
480	roundtrip(t, c)
481	unmapped(t, events, w.ID)
482	if !s.display.Do(func() { s.refreshToplevels() }) {
483		t.Fatal("display stopped")
484	}
485	roundtrip(t, c)
486	if len(log.events) != 0 {
487		t.Fatal("protected foreign close leaked timing")
488	}
489	var retained bool
490	if !s.display.Do(func() { retained = s.toplevelManagers[0].handles[w.ID] != nil }) {
491		t.Fatal("display stopped")
492	}
493	if !retained {
494		t.Fatal("deferred foreign handle lost before unlock")
495	}
496	protect(t, s, state, 2, false)
497	if !s.display.Do(func() { s.refreshToplevels() }) {
498		t.Fatal("display stopped")
499	}
500	got := takeEvents(t, c, log.events)
501	if len(got) != 1 || got[0] != "closed" {
502		t.Fatalf("unlock deferred events: %v", got)
503	}
504	if !s.display.Do(func() { retained = s.toplevelManagers[0].handles[w.ID] != nil; s.refreshToplevels() }) {
505		t.Fatal("display stopped")
506	}
507	if retained {
508		t.Fatal("stale foreign inventory retained on unlock")
509	}
510	roundtrip(t, c)
511	if len(log.events) != 0 {
512		t.Fatal("deferred close sent twice")
513	}
514}

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