1package app
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "os"
9 "path/filepath"
10 "testing"
11 "time"
12
13 "github.com/bnema/neferwl/internal/adapters/config"
14 "github.com/bnema/neferwl/internal/adapters/vulkan"
15 "github.com/bnema/neferwl/internal/ports"
16 "github.com/bnema/wlturbo"
17 clientcore "github.com/bnema/wlturbo/protocol/core"
18 lockclient "github.com/bnema/wlturbo/protocol/extsessionlock"
19 captureclient "github.com/bnema/wlturbo/protocol/screencopy"
20 clientxdg "github.com/bnema/wlturbo/protocol/xdgshell"
21 "golang.org/x/sys/unix"
22)
23
24// Exercise the production gate, backend proofs and protocol owner together.
25// No test-only global registration or injected security events: bufferless
26// locked must come from both real headless owners' affirmative black proofs.
27func TestHeadlessSessionLockEndToEnd(t *testing.T) {
28 runtime := t.TempDir()
29 t.Setenv("XDG_RUNTIME_DIR", runtime)
30 t.Setenv("XDG_STATE_HOME", t.TempDir())
31 shots := t.TempDir()
32 // The test's own capture client is not on the built-in list: disable the check.
33 allowPath := filepath.Join(t.TempDir(), "capture-allow")
34 if err := os.WriteFile(allowPath, []byte("*\n"), 0o644); err != nil {
35 t.Fatal(err)
36 }
37 scenes := make(chan []ports.Scene, 128)
38 script, writer := io.Pipe()
39 defer writer.Close()
40 ctx, cancel := context.WithCancel(context.Background())
41 done := make(chan error, 1)
42 go func() {
43 done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, NoTerminal: true, Config: config.Defaults(), Sizes: [][2]int{{160, 120}, {128, 96}}, ScreenshotDir: shots, Script: script, testScenes: scenes, captureAllowPath: allowPath, captureAllowOwner: uint32(os.Getuid())})
44 }()
45 // Cancellation closes clients' sockets too, bounding blocked Roundtrip.
46 watchdog := time.AfterFunc(20*time.Second, cancel)
47 exited := false
48 t.Cleanup(func() {
49 watchdog.Stop()
50 cancel()
51 if exited {
52 return
53 }
54 select {
55 case err := <-done:
56 // We canceled Run above. Workers may report that cancellation
57 // during shutdown; it is not a failed session-lock assertion.
58 if err != nil && !errors.Is(err, context.Canceled) {
59 t.Errorf("Run: %v", err)
60 }
61 case <-time.After(3 * time.Second):
62 t.Error("Run did not stop")
63 }
64 })
65 check := func(err error) {
66 t.Helper()
67 if err != nil {
68 t.Fatal(err)
69 }
70 }
71 waitScenes := func(what string, accept func([]ports.Scene) bool) []ports.Scene {
72 t.Helper()
73 timer := time.NewTimer(5 * time.Second)
74 defer timer.Stop()
75 for {
76 select {
77 case set := <-scenes:
78 if accept(set) {
79 return set
80 }
81 case err := <-done:
82 exited = true
83 t.Fatalf("app exited waiting for %s: %v", what, err)
84 case <-timer.C:
85 t.Fatalf("missing %s scenes", what)
86 }
87 }
88 }
89 waitScenes("initial outputs", func(set []ports.Scene) bool { return len(set) == 2 })
90 sockets, err := filepath.Glob(filepath.Join(runtime, "wayland-*[0-9]"))
91 check(err)
92 if len(sockets) != 1 {
93 t.Fatalf("sockets: %v", sockets)
94 }
95 connect := func() *wlturbo.Display {
96 t.Helper()
97 c, err := wlturbo.Connect(sockets[0])
98 check(err)
99 t.Cleanup(func() { _ = c.Close() })
100 check(c.Roundtrip())
101 return c
102 }
103 until := func(c *wlturbo.Display, what string, ready func() bool) {
104 t.Helper()
105 deadline := time.Now().Add(5 * time.Second)
106 for !ready() {
107 if time.Now().After(deadline) {
108 t.Fatalf("missing %s", what)
109 }
110 check(c.Roundtrip())
111 }
112 }
113 desktop := connect()
114 // This fails RED until the parent authorizes production advertisement.
115 if _, ok := desktop.Registry().FindGlobal(lockclient.ExtSessionLockManagerInterface); !ok {
116 t.Fatal("production ext_session_lock_manager_v1 global missing: integration prerequisite")
117 }
118 comp := clientcore.NewCompositor(desktop.Context())
119 shm := clientcore.NewShm(desktop.Context())
120 seat := clientcore.NewSeat(desktop.Context())
121 wm := clientxdg.NewXdgWmBase(desktop.Context())
122 wm.OnPing(func(serial uint32) { check(wm.Pong(serial)) })
123 bindTestClient(t, desktop, clientcore.CompositorInterface, 1, comp)
124 bindTestClient(t, desktop, clientcore.ShmInterface, 1, shm)
125 bindTestClient(t, desktop, clientcore.SeatInterface, 1, seat)
126 bindTestClient(t, desktop, clientxdg.XdgWmBaseInterface, 1, wm)
127 keyboard, err := seat.GetKeyboard()
128 check(err)
129 var focused uint32
130 var desktopKeys []uint32
131 keyboard.OnEnter(func(_ uint32, id uint32, _ []byte) { focused = id })
132 keyboard.OnLeave(func(_ uint32, _ uint32) { focused = 0 })
133 keyboard.OnKey(func(_, _, key, state uint32) {
134 if state == 1 {
135 desktopKeys = append(desktopKeys, key)
136 }
137 })
138 surface, err := comp.CreateSurface()
139 check(err)
140 xdg, err := wm.GetXdgSurface(surface)
141 check(err)
142 _, err = xdg.GetToplevel()
143 check(err)
144 var serial uint32
145 xdg.OnConfigure(func(value uint32) { serial = value })
146 check(surface.Commit())
147 until(desktop, "desktop configure", func() bool { return serial != 0 })
148 check(xdg.AckConfigure(serial))
149 buffer := sessionLockBuffer(t, shm, 1, 1, clientcore.FORMAT_ARGB8888)
150 check(surface.Attach(buffer, 0, 0))
151 check(surface.Commit())
152 until(desktop, "desktop focus", func() bool { return focused == surface.ID() })
153 _, err = io.WriteString(writer, "key a\n")
154 check(err)
155 until(desktop, "initial desktop input", func() bool { return len(desktopKeys) != 0 })
156
157 var outputs []*clientcore.Output
158 for name, global := range desktop.Registry().GetGlobals() {
159 if global.Interface != clientcore.OutputInterface {
160 continue
161 }
162 out := clientcore.NewOutput(desktop.Context())
163 check(desktop.Registry().Bind(name, global.Interface, 4, out))
164 outputs = append(outputs, out)
165 }
166 if len(outputs) != 2 {
167 t.Fatalf("outputs: %d", len(outputs))
168 }
169 captures := captureclient.NewScreencopyManager(desktop.Context())
170 bindTestClient(t, desktop, captureclient.ScreencopyManagerInterface, 3, captures)
171 denyCapture := func() {
172 t.Helper()
173 for _, out := range outputs {
174 frame, err := captures.CaptureOutputRegion(0, out, 0, 0, 1, 1)
175 check(err)
176 failed, ready := false, false
177 frame.OnFailed(func() { failed = true })
178 frame.OnReady(func(_, _, _ uint32) { ready = true })
179 frame.OnBufferDone(func() { check(frame.Copy(sessionLockBuffer(t, shm, 1, 1, clientcore.FORMAT_XRGB8888))) })
180 until(desktop, "capture refusal", func() bool { return failed || ready })
181 if !failed || ready {
182 t.Fatal("ordinary capture succeeded while protected")
183 }
184 check(frame.Destroy())
185 }
186 }
187 // Positive control: the same ordinary client and valid SHM destination
188 // can capture before acquisition, so refusal is not a malformed request.
189 // Software Vulkan without sync-file export (lavapipe) refuses every
190 // protocol capture by design, so the control only holds where the device
191 // can capture; the protection assertions below still run everywhere.
192 if captureSupported(t) {
193 control, err := captures.CaptureOutputRegion(0, outputs[0], 0, 0, 1, 1)
194 check(err)
195 controlReady, controlFailed := false, false
196 control.OnReady(func(_, _, _ uint32) { controlReady = true })
197 control.OnFailed(func() { controlFailed = true })
198 control.OnBufferDone(func() { check(control.Copy(sessionLockBuffer(t, shm, 1, 1, clientcore.FORMAT_XRGB8888))) })
199 until(desktop, "unlocked capture control", func() bool { return controlReady || controlFailed })
200 if !controlReady || controlFailed {
201 t.Fatal("valid unlocked capture failed")
202 }
203 check(control.Destroy())
204 } else {
205 t.Log("Vulkan device cannot capture (no sync-file export): positive control skipped")
206 }
207 // Protocol focus/capture completion is not completion of asynchronous PNG
208 // writes. Establish both screenshot baselines before engaging protection.
209 until(desktop, "initial screenshots on both outputs", func() bool {
210 for _, name := range []string{"HEADLESS-1", "HEADLESS-2"} {
211 if _, err := os.Stat(filepath.Join(shots, name, "latest.png")); err != nil {
212 return false
213 }
214 }
215 return true
216 })
217 acquire := func(c *wlturbo.Display) *lockclient.ExtSessionLock {
218 t.Helper()
219 manager := lockclient.NewExtSessionLockManager(c.Context())
220 bindTestClient(t, c, lockclient.ExtSessionLockManagerInterface, 1, manager)
221 lock, err := manager.Lock()
222 check(err)
223 locked, finished := 0, false
224 lock.OnLocked(func() { locked++ })
225 lock.OnFinished(func() { finished = true })
226 until(c, "bufferless locked", func() bool { return locked != 0 || finished })
227 if locked != 1 || finished {
228 t.Fatalf("lock result: locked=%d finished=%v", locked, finished)
229 }
230 check(c.Roundtrip())
231 if locked != 1 {
232 t.Fatalf("duplicate locked: %d", locked)
233 }
234 return lock
235 }
236 locker := connect()
237 lock := acquire(locker) // deliberately no locker surfaces or buffers yet
238 protected := waitScenes("protected black", func(set []ports.Scene) bool {
239 if len(set) != 2 {
240 return false
241 }
242 for _, s := range set {
243 if !s.Security.Protected || len(s.Windows) != 0 || len(s.Layers) != 0 || s.Background != "#000000" || s.Capture != nil || s.CaptureScene != nil {
244 return false
245 }
246 }
247 return true
248 })
249 generation := protected[0].Security.Generation
250 if generation == 0 || protected[1].Security.Generation != generation {
251 t.Fatal("incoherent protection generation")
252 }
253 check(desktop.Roundtrip())
254 if focused != 0 {
255 t.Fatal("desktop retained keyboard focus under lock")
256 }
257 denyCapture()
258 before := sessionLockShots(t, shots)
259 for _, name := range []string{"HEADLESS-1", "HEADLESS-2"} {
260 if _, ok := before[filepath.Join(shots, name, "latest.png")]; !ok {
261 t.Fatalf("missing initial screenshot for %s", name)
262 }
263 }
264
265 // A configured real locker surface supplies an input completion witness:
266 // receipt by the locker proves scripted key processing without timing sleeps.
267 lockComp := clientcore.NewCompositor(locker.Context())
268 lockShm := clientcore.NewShm(locker.Context())
269 lockSeat := clientcore.NewSeat(locker.Context())
270 bindTestClient(t, locker, clientcore.CompositorInterface, 1, lockComp)
271 bindTestClient(t, locker, clientcore.ShmInterface, 1, lockShm)
272 bindTestClient(t, locker, clientcore.SeatInterface, 1, lockSeat)
273 global, ok := locker.Registry().FindGlobal(clientcore.OutputInterface)
274 if !ok {
275 t.Fatal("locker output missing")
276 }
277 lockOutput := clientcore.NewOutput(locker.Context())
278 check(locker.Registry().Bind(global.Name, global.Interface, 4, lockOutput))
279 lockSurface, err := lockComp.CreateSurface()
280 check(err)
281 role, err := lock.GetLockSurface(lockSurface, lockOutput)
282 check(err)
283 configured := false
284 role.OnConfigure(func(serial, width, height uint32) {
285 check(role.AckConfigure(serial))
286 check(lockSurface.Attach(sessionLockBuffer(t, lockShm, int32(width), int32(height), clientcore.FORMAT_ARGB8888), 0, 0))
287 check(lockSurface.Commit())
288 configured = true
289 })
290 lockKeyboard, err := lockSeat.GetKeyboard()
291 check(err)
292 lockFocused, lockKey, lockReleased := false, false, false
293 lockKeyboard.OnEnter(func(_ uint32, id uint32, _ []byte) { lockFocused = id == lockSurface.ID() })
294 lockKeyboard.OnKey(func(_, _, key, state uint32) {
295 if key != 48 {
296 return
297 } // evdev b
298 if state == 1 {
299 lockKey = true
300 } else {
301 lockReleased = true
302 }
303 })
304 until(locker, "locker focus", func() bool { return configured && lockFocused })
305 desktopCount := len(desktopKeys)
306 _, err = io.WriteString(writer, "key b\n")
307 check(err)
308 until(locker, "locker input and release", func() bool { return lockKey && lockReleased })
309 check(desktop.Roundtrip())
310 if len(desktopKeys) != desktopCount {
311 t.Fatal("locker input leaked to desktop")
312 }
313 if after := sessionLockShots(t, shots); !equalSessionLockShots(before, after) {
314 t.Fatal("screenshots changed while protected")
315 }
316
317 check(locker.Close()) // crash never releases the gate
318 denyCapture()
319 replacement := connect()
320 takeover := acquire(replacement)
321 newer := waitScenes("takeover protection", func(set []ports.Scene) bool {
322 return len(set) == 2 && set[0].Security.Protected && set[1].Security.Protected && set[0].Security.Generation > generation
323 })
324 if newer[0].Security.Generation != newer[1].Security.Generation {
325 t.Fatal("takeover generation differs across outputs")
326 }
327 check(takeover.UnlockAndDestroy())
328 check(replacement.Roundtrip())
329 waitScenes("unlocked desktop", func(set []ports.Scene) bool {
330 return len(set) == 2 && !set[0].Security.Protected && !set[1].Security.Protected && set[0].Security.Generation > newer[0].Security.Generation
331 })
332 until(desktop, "restored desktop focus", func() bool { return focused == surface.ID() })
333 _, err = io.WriteString(writer, "key c\n")
334 check(err)
335 until(desktop, "restored desktop input", func() bool { return len(desktopKeys) > desktopCount })
336 if desktopKeys[len(desktopKeys)-1] != 46 {
337 t.Fatalf("restored key: %v, want evdev c=46", desktopKeys)
338 }
339}
340
341// Real protocol SHM is a client buffer, not a renderer fallback or a double.
342func sessionLockBuffer(t *testing.T, shm *clientcore.Shm, width, height int32, format uint32) *clientcore.Buffer {
343 t.Helper()
344 size := width * height * 4
345 fd, err := unix.MemfdCreate("session-lock-client", unix.MFD_CLOEXEC)
346 if err != nil {
347 t.Fatal(err)
348 }
349 if err := unix.Ftruncate(fd, int64(size)); err != nil {
350 unix.Close(fd)
351 t.Fatal(err)
352 }
353 pool, err := shm.CreatePool(fd, size) // generated request consumes fd on success
354 if err != nil {
355 unix.Close(fd)
356 t.Fatal(err)
357 }
358 buffer, err := pool.CreateBuffer(0, width, height, width*4, format)
359 if err != nil {
360 t.Fatal(err)
361 }
362 if err := pool.Destroy(); err != nil {
363 t.Fatal(err)
364 }
365 return buffer
366}
367
368func sessionLockShots(t *testing.T, dir string) map[string][]byte {
369 t.Helper()
370 files, err := filepath.Glob(filepath.Join(dir, "HEADLESS-*", "*.png"))
371 if err != nil {
372 t.Fatal(err)
373 }
374 result := make(map[string][]byte, len(files))
375 for _, path := range files {
376 data, err := os.ReadFile(path)
377 if err != nil {
378 t.Fatal(err)
379 }
380 result[path] = data
381 }
382 return result
383}
384
385func equalSessionLockShots(a, b map[string][]byte) bool {
386 if len(a) != len(b) {
387 return false
388 }
389 for path, data := range a {
390 if !bytes.Equal(data, b[path]) {
391 return false
392 }
393 }
394 return true
395}
396
397// captureSupported probes the device the headless outputs will render on.
398func captureSupported(t *testing.T) bool {
399 t.Helper()
400 r, err := vulkan.New(16, 16)
401 if err != nil {
402 return false
403 }
404 defer r.Close()
405 return r.CaptureSupported()
406}