v0.3.0 cf370a0

session_lock_test.go RawHistoryFolder

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}

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 25

Tags 10

↑↓ moveenter openesc close