main f2d166b

session_lock_input_epoch_test.go RawHistoryFolder

1package wayland
2
3import (
4	"context"
5	"slices"
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/bnema/go-wayland-bindings/server/wayland"
11	"github.com/bnema/neferwl/internal/adapters/logging"
12	"github.com/bnema/neferwl/internal/adapters/sessionsecurity"
13	"github.com/bnema/neferwl/internal/ports"
14	"github.com/bnema/wlturbo"
15	"github.com/bnema/wlturbo/protocol/core"
16)
17
18// This fixture wires real channel ports before starting the display owner.
19func inputEpochLockServer(t *testing.T) (*Server, *sessionsecurity.Gate, chan ports.SecurityState, chan ports.ClientEvent, string) {
20	t.Helper()
21	gate := &sessionsecurity.Gate{}
22	changes := make(chan ports.SecurityState, 16)
23	events := make(chan ports.ClientEvent, 128)
24	dir := t.TempDir()
25	s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Security: gate, Keymap: keymapText(t)}, Channels{Events: events, SecurityChanges: changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16)}, logging.For(context.Background(), "wayland"))
26	if err != nil {
27		t.Fatal(err)
28	}
29	ctx, cancel := context.WithCancel(context.Background())
30	done := make(chan error, 1)
31	go func() { done <- s.Run(ctx) }()
32	t.Cleanup(func() {
33		cancel()
34		select {
35		case err := <-done:
36			if err != nil {
37				t.Error(err)
38			}
39		case <-time.After(3 * time.Second):
40			t.Error("input epoch server did not stop")
41		}
42	})
43	return s, gate, changes, events, dir
44}
45
46func inputEpochKeyboard(t *testing.T, c *wlturbo.Display) *core.Keyboard {
47	t.Helper()
48	seat := core.NewSeat(c.Context())
49	g, ok := c.Registry().FindGlobal("wl_seat")
50	if !ok {
51		t.Fatal("seat missing")
52	}
53	if err := c.Registry().Bind(g.Name, g.Interface, min(g.Version, 4), seat); err != nil {
54		t.Fatal(err)
55	}
56	keyboard, err := seat.GetKeyboard()
57	if err != nil {
58		t.Fatal(err)
59	}
60	return keyboard
61}
62
63func inputEpochCommitLocker(t *testing.T, s *Server, c *wlturbo.Display, lockSurface *core.Surface, ack func(uint32) error, onConfigure func(func(uint32, uint32, uint32))) (ports.WindowID, lockConfigure) {
64	t.Helper()
65	var cfg lockConfigure
66	onConfigure(func(serial, w, h uint32) { cfg = lockConfigure{serial: serial, width: int(w), height: int(h)} })
67	roundtrip(t, c)
68	if cfg.serial == 0 {
69		t.Fatal("missing lock configure")
70	}
71	if err := ack(cfg.serial); err != nil {
72		t.Fatal(err)
73	}
74	lockViewport(t, c, lockSurface.ID(), cfg.width, cfg.height)
75	requestProtocol(t, c, lockSurface.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
76	requestProtocol(t, c, lockSurface.ID(), wayland.SurfaceRequestCommit)
77	roundtrip(t, c)
78	var id ports.WindowID
79	s.display.Do(func() {
80		surf := s.surfaceOf(findTestSurface(s, lockSurface.ID()))
81		if surf == nil || surf.lock == nil || !surf.lock.mapped {
82			t.Error("acknowledged buffered lock role not mapped")
83			return
84		}
85		id = surf.lock.id
86	})
87	return id, cfg
88}
89
90// Marker follows display-owned event emission; no sleeps or polling are
91// needed to establish that all earlier SessionLockChanged events were read.
92func inputEpochLockEvents(t *testing.T, s *Server, events <-chan ports.ClientEvent) []ports.SessionLockChanged {
93	t.Helper()
94	const marker ports.WindowID = ^ports.WindowID(0)
95	if !s.display.Do(func() { s.emit(ports.WindowAppID{ID: marker, AppID: "input-epoch-marker"}) }) {
96		t.Fatal("display stopped")
97	}
98	deadline := time.After(2 * time.Second)
99	var locks []ports.SessionLockChanged
100	for {
101		select {
102		case ev := <-events:
103			if v, ok := ev.(ports.WindowAppID); ok && v.ID == marker {
104				return locks
105			}
106			if v, ok := ev.(ports.SessionLockChanged); ok {
107				locks = append(locks, v)
108			}
109		case <-deadline:
110			t.Fatal("event marker missing")
111			return nil
112		}
113	}
114}
115
116func TestSessionLockInputEpochHeldPasswordClearedBeforeDesktopEnter(t *testing.T) {
117	s, gate, changes, events, dir := inputEpochLockServer(t)
118	desktop := protocolClient(t, s, dir)
119	normal, normalSurface, _ := surfaceMapper(t, desktop, events)()
120	keys := inputEpochKeyboard(t, desktop)
121	var enters [][]byte
122	var enterSurfaces []uint32
123	var mods []ports.ModState
124	var delivered []uint32
125	keys.OnEnter(func(_ uint32, surface uint32, held []byte) {
126		enters = append(enters, slices.Clone(held))
127		enterSurfaces = append(enterSurfaces, surface)
128	})
129	keys.OnModifiers(func(_ uint32, d, l, k, g uint32) {
130		mods = append(mods, ports.ModState{Depressed: d, Latched: l, Locked: k, Group: g})
131	})
132	keys.OnKey(func(_, _, key, _ uint32) { delivered = append(delivered, key) })
133	roundtrip(t, desktop)
134	s.display.Do(func() {
135		s.apply(ports.SecurityCommand{State: gate.Snapshot(), Command: ports.FocusWindow{ID: normal.ID}})
136	})
137	roundtrip(t, desktop)
138	enters, enterSurfaces, mods, delivered = nil, nil, nil, nil
139	ime := newIMEApp(t, s, dir)
140	_, grab := ime.grab(t)
141	roundtrip(t, ime.c)
142	grab.take()
143	locker := protocolClient(t, s, dir)
144	lock, locked := lifecycleAcquire(t, locker)
145	state := lifecycleTransition(t, changes)
146	surf, out := lifecycleSurfaceOutput(t, locker)
147	role, err := lock.GetLockSurface(surf, out)
148	if err != nil {
149		t.Fatal(err)
150	}
151	id, _ := inputEpochCommitLocker(t, s, locker, surf, role.AckConfigure, role.OnConfigure)
152	lockerKeys := inputEpochKeyboard(t, locker)
153	var passwordKeys []uint32
154	lockerKeys.OnKey(func(_, _, key, _ uint32) { passwordKeys = append(passwordKeys, key) })
155	roundtrip(t, locker)
156	s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
157	roundtrip(t, locker)
158	if *locked != 1 {
159		t.Fatal("lock not confirmed")
160	}
161	password := ports.KeyEvent{Keycode: 30, Pressed: true, Time: 1 * time.Millisecond, State: ports.ModState{Depressed: 65, Latched: 2, Locked: 16, Group: 1}}
162	s.display.Do(func() {
163		s.apply(ports.SecurityCommand{State: state, Command: ports.FocusWindow{ID: id}})
164		s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: id, Key: password}})
165		if !s.seat.heldKeys[30] || s.seat.modState != password.State {
166			t.Error("test did not seed held password/native seat modifier state")
167		}
168	})
169	settle(t, locker, ime.c, desktop)
170	if !slices.Equal(passwordKeys, []uint32{30}) {
171		t.Fatalf("locker key events %v", passwordKeys)
172	}
173	if len(delivered) != 0 {
174		t.Fatalf("locker password reached desktop before release: %v", delivered)
175	}
176	for _, event := range grab.take() {
177		if strings.HasPrefix(event, "key ") {
178			t.Fatalf("password reached IME: %s", event)
179		}
180	}
181	if err := lock.UnlockAndDestroy(); err != nil {
182		t.Fatal(err)
183	}
184	roundtrip(t, locker)
185	released := lifecycleTransition(t, changes)
186	if released.Protected || released == state {
187		t.Fatal(released)
188	}
189	s.display.Do(func() {
190		if len(s.seat.heldKeys) != 0 || len(s.seat.grabKeys) != 0 || s.seat.modState != (ports.ModState{}) {
191			t.Error("release retained password input state")
192		}
193		s.apply(ports.SecurityCommand{State: state, Command: ports.FocusWindow{ID: normal.ID}})
194		s.apply(ports.FocusWindow{ID: normal.ID})
195		if s.seat.focused != 0 {
196			t.Error("stale or raw focus crossed release")
197		}
198		s.apply(ports.SecurityCommand{State: released, Command: ports.FocusWindow{ID: normal.ID}})
199		// Old envelopes aimed at the newly focused desktop are still rejected.
200		s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: normal.ID, Key: password}})
201		password.Pressed = false
202		s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: normal.ID, Key: password}})
203		s.apply(ports.ForwardKey{ID: normal.ID, Key: password})
204	})
205	settle(t, desktop, locker, ime.c)
206	if len(delivered) != 0 {
207		t.Fatalf("password/stale key reached desktop: %v", delivered)
208	}
209	if len(enters) != 1 || enterSurfaces[0] != normalSurface || len(enters[0]) != 0 {
210		t.Fatalf("desktop enter surfaces=%v held=%v", enterSurfaces, enters)
211	}
212	if len(mods) == 0 || mods[len(mods)-1] != (ports.ModState{}) {
213		t.Fatalf("desktop modifiers not reset: %+v", mods)
214	}
215	for _, event := range grab.take() {
216		if strings.HasPrefix(event, "key ") {
217			t.Fatalf("stale password reached IME after release: %s", event)
218		}
219	}
220}
221
222func TestSessionLockInputEpochMappedRolePublishedOnlyAfterEveryProof(t *testing.T) {
223	s, _, changes, events, dir := inputEpochLockServer(t)
224	c := protocolClient(t, s, dir)
225	s.display.Do(func() {
226		s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 11, Output: "HEADLESS-1"})
227		s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 12, Output: "HEADLESS-2"})
228	})
229	lock, locked := lifecycleAcquire(t, c)
230	state := lifecycleTransition(t, changes)
231	surf, out := lifecycleSurfaceOutput(t, c)
232	role, err := lock.GetLockSurface(surf, out)
233	if err != nil {
234		t.Fatal(err)
235	}
236	id, cfg := inputEpochCommitLocker(t, s, c, surf, role.AckConfigure, role.OnConfigure)
237	before := inputEpochLockEvents(t, s, events)
238	if len(before) == 0 {
239		t.Fatal("no core protection event")
240	}
241	for _, ev := range before {
242		if ev.State != state || len(ev.Surfaces) != 0 {
243			t.Fatalf("preconfirm placement leaked %+v", ev)
244		}
245	}
246	s.display.Do(func() {
247		s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation})
248		lifecycleProof(s, state, 11)
249	})
250	roundtrip(t, c)
251	if *locked != 0 {
252		t.Fatal("locked without second output proof")
253	}
254	for _, ev := range inputEpochLockEvents(t, s, events) {
255		if len(ev.Surfaces) != 0 {
256			t.Fatalf("one-proof placement leaked %+v", ev)
257		}
258	}
259	s.display.Do(func() { lifecycleProof(s, state, 12) })
260	roundtrip(t, c)
261	if *locked != 1 {
262		t.Fatalf("locked callbacks %d", *locked)
263	}
264	after := inputEpochLockEvents(t, s, events)
265	if cfg.width != testOutputs[0].Width || cfg.height != testOutputs[0].Height {
266		t.Fatalf("lock configure %+v does not match output %+v", cfg, testOutputs[0])
267	}
268	want := ports.LockSurfacePlacement{ID: id, Output: "HEADLESS-1", Width: cfg.width, Height: cfg.height}
269	if len(after) != 1 || after[0].State != state || !slices.Equal(after[0].Surfaces, []ports.LockSurfacePlacement{want}) {
270		t.Fatalf("validated placement events %+v want %+v", after, want)
271	}
272}

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