1package wayland
2
3import (
4 "context"
5 "slices"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/bnema/go-wayland-bindings/server/drmlease"
11 serverlock "github.com/bnema/go-wayland-bindings/server/extsessionlock"
12 "github.com/bnema/go-wayland-bindings/server/wayland"
13 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
14 "github.com/bnema/neferwl/internal/logging"
15 "github.com/bnema/neferwl/internal/ports"
16 "github.com/bnema/wlturbo"
17 "github.com/bnema/wlturbo/protocol/core"
18 lockclient "github.com/bnema/wlturbo/protocol/extsessionlock"
19 "golang.org/x/sys/unix"
20)
21
22// A real display with capture completion wiring established before Run starts.
23func lifecycleLockServer(t *testing.T, leaseRequests ...chan ports.LeaseMessage) (*Server, *sessionsecurity.Gate, chan ports.SecurityState, chan ports.CaptureDone, string) {
24 t.Helper()
25 gate := &sessionsecurity.Gate{}
26 changes := make(chan ports.SecurityState, 16)
27 captured := make(chan ports.CaptureDone, 16)
28 dir := t.TempDir()
29 channels := Channels{SecurityChanges: changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16), Captured: captured}
30 if len(leaseRequests) != 0 {
31 channels.LeaseRequests = leaseRequests[0]
32 }
33 s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Security: gate, Keymap: keymapText(t)}, channels, logging.For(context.Background(), "wayland"))
34 if err != nil {
35 t.Fatal(err)
36 }
37 ctx, cancel := context.WithCancel(context.Background())
38 done := make(chan error, 1)
39 go func() { done <- s.Run(ctx) }()
40 t.Cleanup(func() {
41 cancel()
42 select {
43 case err := <-done:
44 if err != nil {
45 t.Error(err)
46 }
47 case <-time.After(3 * time.Second):
48 t.Error("lock lifecycle server did not stop")
49 }
50 })
51 return s, gate, changes, captured, dir
52}
53
54func lifecycleTransition(t *testing.T, changes <-chan ports.SecurityState) ports.SecurityState {
55 t.Helper()
56 select {
57 case state := <-changes:
58 return state
59 case <-time.After(2 * time.Second):
60 t.Fatal("missing security transition")
61 return ports.SecurityState{}
62 }
63}
64
65func lifecycleAcquire(t *testing.T, c *wlturbo.Display) (*lockclient.ExtSessionLock, *int) {
66 t.Helper()
67 lock, err := bindLockManager(t, c).Lock()
68 if err != nil {
69 t.Fatal(err)
70 }
71 count := new(int)
72 lock.OnLocked(func() { *count++ })
73 roundtrip(t, c)
74 return lock, count
75}
76
77func lifecycleProof(s *Server, state ports.SecurityState, instance ports.OutputInstance) {
78 s.applySecurityEvent(ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}})
79}
80
81func TestSessionLockLifecycleCaptureCompletionAndProofEpoch(t *testing.T) {
82 s, gate, changes, captured, dir := lifecycleLockServer(t)
83 c := protocolClient(t, s, dir)
84 s.display.Do(func() {
85 s.captureInflight[77] = struct{}{}
86 s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "HEADLESS-1"})
87 })
88 _, locked := lifecycleAcquire(t, c)
89 state := lifecycleTransition(t, changes)
90 s.display.Do(func() {
91 s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation})
92 lifecycleProof(s, ports.SecurityState{Generation: state.Generation + 1}, 1)
93 lifecycleProof(s, state, 99) // unregistered output must not satisfy inventory
94 lifecycleProof(s, state, 1)
95 if s.sessionLock.locked {
96 t.Error("capture credit bypassed")
97 }
98 })
99 roundtrip(t, c)
100 if *locked != 0 {
101 t.Fatal("locked before capture completion")
102 }
103 // Unknown completion cannot retire an outstanding credit.
104 unknown := make(chan struct{})
105 s.display.Do(func() { s.captureReplies[88] = func(ports.CaptureDone) { close(unknown) } })
106 captured <- ports.CaptureDone{ID: 88}
107 select {
108 case <-unknown:
109 case <-time.After(2 * time.Second):
110 t.Fatal("unknown completion not consumed")
111 }
112 s.display.Do(func() {
113 if _, exists := s.captureInflight[77]; !exists || s.sessionLock.locked {
114 t.Error("unknown completion retired real credit")
115 }
116 })
117 processed := make(chan struct{})
118 s.display.Do(func() { s.captureReplies[77] = func(ports.CaptureDone) { close(processed) } })
119 captured <- ports.CaptureDone{ID: 77}
120 select {
121 case <-processed:
122 case <-time.After(2 * time.Second):
123 t.Fatal("capture completion not consumed")
124 }
125 roundtrip(t, c)
126 if *locked != 1 || !gate.Snapshot().Protected {
127 t.Fatalf("completion locked=%d gate=%+v", *locked, gate.Snapshot())
128 }
129 s.display.Do(func() { lifecycleProof(s, state, 1); s.maybeLocked() })
130 roundtrip(t, c)
131 if *locked != 1 {
132 t.Fatal("locked event repeated")
133 }
134}
135
136func TestSessionLockLifecycleUnlockProtocolErrors(t *testing.T) {
137 for _, scenario := range []string{"unlock before locked", "destroy after locked"} {
138 t.Run(scenario, func(t *testing.T) {
139 s, gate, changes, _, dir := lockManagerServer(t)
140 c := protocolClient(t, s, dir)
141 lock, locked := lifecycleAcquire(t, c)
142 state := lifecycleTransition(t, changes)
143 code := serverlock.ExtSessionLockV1ErrorInvalidUnlock
144 id := lock.ID()
145 if scenario == "destroy after locked" {
146 s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
147 roundtrip(t, c)
148 if *locked != 1 {
149 t.Fatal("zero-output backend barrier did not confirm")
150 }
151 code = serverlock.ExtSessionLockV1ErrorInvalidDestroy
152 if err := lock.Destroy(); err != nil {
153 t.Fatal(err)
154 }
155 } else {
156 if err := lock.UnlockAndDestroy(); err != nil {
157 t.Fatal(err)
158 }
159 }
160 expectProtocolError(t, c, id, uint32(code))
161 if !gate.Snapshot().Protected {
162 t.Fatal("invalid destructor unlocked")
163 }
164 })
165 }
166}
167
168func TestSessionLockLifecycleOrphanTakeoverAndValidUnlock(t *testing.T) {
169 s, gate, changes, _, dir := lockManagerServer(t)
170 c := protocolClient(t, s, dir)
171 _, locked := lifecycleAcquire(t, c)
172 firstState := lifecycleTransition(t, changes)
173 s.display.Do(func() {
174 s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "HEADLESS-1"})
175 s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: firstState.Generation})
176 lifecycleProof(s, firstState, 1)
177 })
178 roundtrip(t, c)
179 if *locked != 1 {
180 t.Fatal("first lock not confirmed")
181 }
182 orphaned := make(chan struct{})
183 s.display.Do(func() { r := s.sessionLock.res; old := r.OnDestroy; r.OnDestroy = func() { old(); close(orphaned) } })
184 if err := c.Close(); err != nil {
185 t.Fatal(err)
186 }
187 select {
188 case <-orphaned:
189 case <-time.After(2 * time.Second):
190 t.Fatal("owner disconnect not processed")
191 }
192 s.display.Do(func() {
193 if s.sessionLock != nil || !gate.Snapshot().Protected {
194 t.Error("orphan released protection")
195 }
196 })
197 next := protocolClient(t, s, dir)
198 second, secondLocked := lifecycleAcquire(t, next)
199 state := lifecycleTransition(t, changes)
200 if state.Generation <= firstState.Generation || !state.Protected {
201 t.Fatalf("takeover state %+v", state)
202 }
203 s.display.Do(func() {
204 s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation})
205 lifecycleProof(s, firstState, 1)
206 })
207 roundtrip(t, next)
208 if *secondLocked != 0 {
209 t.Fatal("stale proof legalized takeover")
210 }
211 s.display.Do(func() { lifecycleProof(s, state, 1) })
212 roundtrip(t, next)
213 if *secondLocked != 1 {
214 t.Fatal("takeover not confirmed")
215 }
216 if err := second.UnlockAndDestroy(); err != nil {
217 t.Fatal(err)
218 }
219 roundtrip(t, next) // explicit server processing before client exit
220 released := lifecycleTransition(t, changes)
221 s.display.Do(func() {
222 if s.sessionLock != nil || gate.Snapshot().Protected || len(s.seat.heldKeys) != 0 || len(s.seat.grabKeys) != 0 {
223 t.Error("valid unlock failed to reset ownership/input")
224 }
225 })
226 if released.Protected || released.Generation <= state.Generation {
227 t.Fatalf("unlock transition %+v", released)
228 }
229}
230
231func lifecycleSurfaceOutput(t *testing.T, c *wlturbo.Display) (*core.Surface, *core.Output) {
232 t.Helper()
233 comp := core.NewCompositor(c.Context())
234 g, ok := c.Registry().FindGlobal("wl_compositor")
235 if !ok {
236 t.Fatal("compositor missing")
237 }
238 if err := c.Registry().Bind(g.Name, g.Interface, 6, comp); err != nil {
239 t.Fatal(err)
240 }
241 surf, err := comp.CreateSurface()
242 if err != nil {
243 t.Fatal(err)
244 }
245 out := core.NewOutput(c.Context())
246 g, ok = c.Registry().FindGlobal("wl_output")
247 if !ok {
248 t.Fatal("output missing")
249 }
250 if err := c.Registry().Bind(g.Name, g.Interface, 4, out); err != nil {
251 t.Fatal(err)
252 }
253 return surf, out
254}
255
256func TestSessionLockLifecycleRefusedPipelinedSurfaceInert(t *testing.T) {
257 s, gate, changes, _, dir := lockManagerServer(t)
258 c := protocolClient(t, s, dir)
259 manager := bindLockManager(t, c)
260 accepted, err := manager.Lock()
261 if err != nil {
262 t.Fatal(err)
263 }
264 refused, err := manager.Lock()
265 if err != nil {
266 t.Fatal(err)
267 }
268 finished, configured := 0, 0
269 refused.OnFinished(func() { finished++ })
270 surf, out := lifecycleSurfaceOutput(t, c)
271 role, err := refused.GetLockSurface(surf, out)
272 if err != nil {
273 t.Fatal(err)
274 }
275 role.OnConfigure(func(uint32, uint32, uint32) { configured++ })
276 requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
277 requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestCommit)
278 roundtrip(t, c)
279 state := lifecycleTransition(t, changes)
280 s.display.Do(func() {
281 stateSurf := s.surfaceOf(findTestSurface(s, surf.ID()))
282 if stateSurf.kind != roleNone || stateSurf.lock != nil || len(s.lockSurfaces) != 0 || s.sessionLock.res.ID() != accepted.ID() || s.lockInputTarget(stateSurf.windowID()) {
283 t.Error("refused surface gained active role/input")
284 }
285 })
286 if finished != 1 || configured != 0 || gate.Snapshot() != state {
287 t.Fatalf("refused finished=%d configure=%d", finished, configured)
288 }
289 if err := role.Destroy(); err != nil {
290 t.Fatal(err)
291 }
292 if err := refused.Destroy(); err != nil {
293 t.Fatal(err)
294 }
295 roundtrip(t, c)
296}
297
298func TestSessionLockLifecycleIMEGrabCannotReceiveLockerKey(t *testing.T) {
299 s, _, changes, _, dir := lifecycleLockServer(t)
300 ime := newIMEApp(t, s, dir)
301 _, grab := ime.grab(t)
302 roundtrip(t, ime.c)
303 grab.take()
304 locker := protocolClient(t, s, dir)
305 lock, locked := lifecycleAcquire(t, locker)
306 state := lifecycleTransition(t, changes)
307 surf, out := lifecycleSurfaceOutput(t, locker)
308 role, err := lock.GetLockSurface(surf, out)
309 if err != nil {
310 t.Fatal(err)
311 }
312 var cfg lockConfigure
313 role.OnConfigure(func(serial, w, h uint32) { cfg = lockConfigure{serial: serial, width: int(w), height: int(h)} })
314 roundtrip(t, locker)
315 if cfg.serial == 0 {
316 t.Fatal("configure missing")
317 }
318 if err := role.AckConfigure(cfg.serial); err != nil {
319 t.Fatal(err)
320 }
321 lockViewport(t, locker, surf.ID(), cfg.width, cfg.height)
322 requestProtocol(t, locker, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, locker), int32(0), int32(0))
323 requestProtocol(t, locker, surf.ID(), wayland.SurfaceRequestCommit)
324 seat := bindProtocol(t, locker, "wl_seat")
325 keys := &eventsProxy{}
326 keys.SetID(locker.AllocateID())
327 registerWireProxy(locker, keys)
328 requestProtocol(t, locker, seat, wayland.SeatRequestGetKeyboard, keys.ID())
329 roundtrip(t, locker)
330 s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
331 roundtrip(t, locker)
332 if *locked != 1 {
333 t.Fatal("lock not confirmed")
334 }
335 s.display.Do(func() {
336 l := s.surfaceOf(findTestSurface(s, surf.ID())).lock
337 s.apply(ports.SecurityCommand{State: state, Command: ports.FocusWindow{ID: l.id}})
338 s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: l.id, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1}}})
339 })
340 settle(t, locker, ime.c)
341 for _, event := range grab.take() {
342 if strings.HasPrefix(event, "key ") {
343 t.Fatalf("locker key leaked to IME: %s", event)
344 }
345 }
346 if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventKey)) {
347 t.Fatal("locker did not receive key")
348 }
349}
350
351func TestSessionLockLifecycleLatePrelockLeaseRevokedAfterUnlock(t *testing.T) {
352 requests := make(chan ports.LeaseMessage, 16)
353 s, gate, changes, _, dir := lifecycleLockServer(t, requests)
354 c := protocolClient(t, s, dir)
355 const card = "/dev/dri/card-lifecycle"
356 s.display.Do(func() {
357 s.applyLeaseEvent(ports.LeaseConnectors{Card: card, Device: leaseFD(t), Connectors: []ports.LeaseConnector{{Card: card, Name: "DP-1", ConnectorID: 42}}})
358 })
359 roundtrip(t, c)
360 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")
361 device := &leaseDeviceEvents{c: c}
362 device.SetID(dev)
363 registerWireProxy(c, device)
364 roundtrip(t, c)
365 for _, fd := range device.fds {
366 defer unix.Close(fd)
367 }
368 if len(device.connectors) != 1 {
369 t.Fatal("lease connector missing")
370 }
371 req := c.AllocateID()
372 registerProtocol(t, c, req)
373 requestProtocol(t, c, dev, drmlease.WpDrmLeaseDeviceV1RequestCreateLeaseRequest, req)
374 requestProtocol(t, c, req, drmlease.WpDrmLeaseRequestV1RequestRequestConnector, device.connectors[0])
375 result := &leaseResultEvents{}
376 result.SetID(c.AllocateID())
377 registerWireProxy(c, result)
378 requestProtocol(t, c, req, drmlease.WpDrmLeaseRequestV1RequestSubmit, result.ID())
379 roundtrip(t, c)
380 var pending ports.LeaseRequest
381 select {
382 case message := <-requests:
383 var ok bool
384 pending, ok = message.(ports.LeaseRequest)
385 if !ok {
386 t.Fatalf("request %T", message)
387 }
388 case <-time.After(2 * time.Second):
389 t.Fatal("lease request not sent")
390 }
391 lock, locked := lifecycleAcquire(t, c)
392 state := lifecycleTransition(t, changes)
393 s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
394 roundtrip(t, c)
395 if *locked != 1 || result.finished != 1 {
396 t.Fatalf("acquire locked=%d lease finished=%d", *locked, result.finished)
397 }
398 if err := lock.UnlockAndDestroy(); err != nil {
399 t.Fatal(err)
400 }
401 roundtrip(t, c)
402 lifecycleTransition(t, changes)
403 if gate.Snapshot().Protected {
404 t.Fatal("valid unlock failed")
405 }
406 fd := leaseFD(t)
407 s.display.Do(func() { s.applyLeaseEvent(ports.LeaseReply{ID: pending.ID, LeaseID: 123, FD: fd}) })
408 roundtrip(t, c)
409 if _, err := fd.Stat(); err == nil {
410 t.Fatal("late lease FD not closed")
411 }
412 select {
413 case message := <-requests:
414 revoke, ok := message.(ports.LeaseRevoke)
415 if !ok || revoke.Card != card || revoke.LeaseID != 123 {
416 t.Fatalf("late reply request %+v", message)
417 }
418 case <-time.After(2 * time.Second):
419 t.Fatal("late lease was not revoked")
420 }
421 s.display.Do(func() {
422 if len(s.pendingLeases) != 0 || len(s.pendingLeaseCards) != 0 || len(s.activeLeases) != 0 {
423 t.Error("late reply retained authority")
424 }
425 })
426 if len(result.fds) != 0 {
427 for _, fd := range result.fds {
428 unix.Close(fd)
429 }
430 t.Fatal("late lease FD delivered after unlock")
431 }
432}
433
434func TestSessionLockLifecycleRoleUnmapSendsSeatLeaves(t *testing.T) {
435 for _, kind := range []string{"role destroy", "output removal"} {
436 t.Run(kind, func(t *testing.T) {
437 s, gate, changes, _, dir := lifecycleLockServer(t)
438 c := protocolClient(t, s, dir)
439 lock, locked := lifecycleAcquire(t, c)
440 state := lifecycleTransition(t, changes)
441 surf, out := lifecycleSurfaceOutput(t, c)
442 role, err := lock.GetLockSurface(surf, out)
443 if err != nil {
444 t.Fatal(err)
445 }
446 var cfg lockConfigure
447 role.OnConfigure(func(serial, w, h uint32) { cfg = lockConfigure{serial: serial, width: int(w), height: int(h)} })
448 roundtrip(t, c)
449 if err := role.AckConfigure(cfg.serial); err != nil {
450 t.Fatal(err)
451 }
452 lockViewport(t, c, surf.ID(), cfg.width, cfg.height)
453 requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
454 requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestCommit)
455 seat := bindProtocol(t, c, "wl_seat")
456 keys := &eventsProxy{}
457 keys.SetID(c.AllocateID())
458 registerWireProxy(c, keys)
459 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keys.ID())
460 pointer := newIMELog(c, c.AllocateID(), "enter:uoff", "leave:uo", "motion:uff", "button:uuuu", "axis:uuf")
461 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer.ID())
462 roundtrip(t, c)
463 s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
464 roundtrip(t, c)
465 if *locked != 1 {
466 t.Fatal("lock not confirmed")
467 }
468 s.display.Do(func() {
469 l := s.surfaceOf(findTestSurface(s, surf.ID())).lock
470 s.changeFocus(l.id)
471 s.changePointerFocus(l.id, 1, 2)
472 })
473 roundtrip(t, c)
474 if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventEnter)) {
475 t.Fatal("keyboard enter missing")
476 }
477 keys.opcodes = nil
478 pointer.take()
479 if kind == "role destroy" {
480 if err := role.Destroy(); err != nil {
481 t.Fatal(err)
482 }
483 } else {
484 s.display.Do(func() { s.setOutputs(ports.SetOutputs{}) })
485 }
486 roundtrip(t, c)
487 if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventLeave)) {
488 t.Fatal("live lock keyboard did not receive leave")
489 }
490 leave := false
491 for _, event := range pointer.take() {
492 if strings.HasPrefix(event, "leave ") {
493 leave = true
494 }
495 }
496 if !leave {
497 t.Fatal("live lock pointer did not receive leave")
498 }
499 s.display.Do(func() {
500 if s.seat.focused != 0 || s.seat.pointerFocus != 0 || !gate.Snapshot().Protected || !findTestSurface(s, surf.ID()).Alive() {
501 t.Error("unmap lost protection/live wl_surface or retained seat focus")
502 }
503 })
504 })
505 }
506}
507
508func TestSessionLockLifecycleBackendOutboxPreservesBlockedTransitions(t *testing.T) {
509 s, gate, changes, _, dir := lockManagerServer(t)
510 // Fill the backend channel before any transition; display remains responsive.
511 sentinel := ports.SecurityState{Generation: 999}
512 changes <- sentinel
513 c := protocolClient(t, s, dir)
514 lock, locked := lifecycleAcquire(t, c)
515 state := gate.Snapshot()
516 s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
517 roundtrip(t, c)
518 if *locked != 1 {
519 t.Fatal("test protection proof not consumed")
520 }
521 if err := lock.UnlockAndDestroy(); err != nil {
522 t.Fatal(err)
523 }
524 roundtrip(t, c)
525 released := gate.Snapshot()
526 s.display.Do(func() {
527 if len(s.securityPending) < 2 || s.securityPending[0] != state || s.securityPending[1] != released {
528 t.Errorf("blocked outbox lost ordered transitions: %+v", s.securityPending)
529 }
530 })
531 if got := lifecycleTransition(t, changes); got != sentinel {
532 t.Fatalf("sentinel %+v", got)
533 }
534 if got := lifecycleTransition(t, changes); got != state {
535 t.Fatalf("acquire %+v", got)
536 }
537 if got := lifecycleTransition(t, changes); got != released {
538 t.Fatalf("release %+v", got)
539 }
540 if released.Protected || released.Generation <= state.Generation {
541 t.Fatal("unlock generation did not advance")
542 }
543}