session_lock_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close