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}