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}