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