captureauth_test.go RawHistoryFolder

1package wayland
2
3import (
4	"errors"
5	"os"
6	"path/filepath"
7	"strconv"
8	"strings"
9	"sync/atomic"
10	"testing"
11	"time"
12
13	source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
14	ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
15	"github.com/bnema/neferwl/internal/adapters/captureallow"
16	"github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
17	"github.com/bnema/neferwl/internal/ports"
18	"github.com/bnema/zerowrap"
19	"github.com/stretchr/testify/mock"
20	"golang.org/x/sys/unix"
21)
22
23func allowStore(t *testing.T, content string) *captureallow.Store {
24	t.Helper()
25	path := filepath.Join(t.TempDir(), "capture-allow")
26	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
27		t.Fatal(err)
28	}
29	return captureallow.NewStoreOwnedBy(path, uint32(os.Getuid()), zerowrap.Default())
30}
31
32func authHarness(t *testing.T, store *captureallow.Store, peer peerExe) (*admissionHarness, *lockedBuffer) {
33	t.Helper()
34	var out lockedBuffer
35	log := zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &out}).WithField("component", "wayland")
36	return newAdmissionHarnessWith(t, maxCaptureInflight+2, Options{CaptureAllow: store, peer: peer}, log), &out
37}
38
39// The executable behind a real client connection from this very process is
40// the test binary: a list naming it lets the client capture, one naming
41// something else does not. No test double for the peer lookup.
42func TestCaptureAllowlistRealPeer(t *testing.T) {
43	self, err := os.Executable()
44	if err != nil {
45		t.Fatal(err)
46	}
47	h, out := authHarness(t, allowStore(t, "# mine\n"+self+"\n"), nil)
48	a := h.client(t)
49	if _, ok := h.accepted(t, a.captureWlr(t)); !ok {
50		t.Fatal("listed executable refused")
51	}
52	if strings.Contains(out.String(), `"message":"capture refused"`) {
53		t.Fatalf("log = %s", out.String())
54	}
55
56	h2, out2 := authHarness(t, allowStore(t, "/usr/bin/grim\n"), nil)
57	b := h2.client(t)
58	for i := 0; i < 3; i++ {
59		if _, ok := h2.accepted(t, b.captureWlr(t)); ok {
60			t.Fatal("unlisted executable captured")
61		}
62	}
63	log := out2.String()
64	if n := strings.Count(log, `"message":"capture refused"`); n != 1 {
65		t.Fatalf("refusal logged %d times, want once per client: %s", n, log)
66	}
67	for _, want := range []string{`"component":"wayland"`, `"exe":"` + self + `"`, `"pid":` + strconv.Itoa(os.Getpid())} {
68		if !strings.Contains(log, want) {
69			t.Fatalf("log lacks %s: %s", want, log)
70		}
71	}
72}
73
74// Every capture path asks: wlr-screencopy frames and ext sessions. A refused
75// ext session is stopped at once, before any constraint.
76func TestCaptureAllowlistEveryPath(t *testing.T) {
77	h, _ := authHarness(t, allowStore(t, "/usr/bin/grim\n"), nil)
78	a := h.client(t)
79	if _, ok := h.accepted(t, a.captureWlr(t)); ok {
80		t.Fatal("wlr capture allowed")
81	}
82	if !a.extSessionStopped(t) {
83		t.Fatal("ext session not refused")
84	}
85}
86
87// extSessionStopped opens an ext capture session and reports whether the
88// compositor stopped it at once, as it does for a client that may not capture.
89func (a *admissionClient) extSessionStopped(t *testing.T) bool {
90	t.Helper()
91	c := a.c
92	sourceManager := bindProtocol(t, c, "ext_output_image_capture_source_manager_v1")
93	manager := bindProtocol(t, c, "ext_image_copy_capture_manager_v1")
94	src, session := c.AllocateID(), c.AllocateID()
95	registerProtocol(t, c, src)
96	requestProtocol(t, c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, a.output)
97	events := &captureEvents{events: make(chan uint16, 16)}
98	events.SetID(session)
99	registerWireProxy(c, events)
100	requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
101	if err := c.Roundtrip(); err != nil {
102		t.Fatal(err)
103	}
104	return <-events.events == uint16(ext.ExtImageCopyCaptureSessionV1EventStopped)
105}
106
107func TestCaptureAllowlistModes(t *testing.T) {
108	self, _ := os.Executable()
109	tests := []struct {
110		name, content string
111		want          bool
112	}{
113		{"star", "*\n", true},
114		{"listed", self + "\n", true},
115		{"other", "/usr/bin/grim\n", false},
116		{"empty", "", false},
117		{"invalid", self + "\nnot-absolute\n", false},
118	}
119	for _, tc := range tests {
120		t.Run(tc.name, func(t *testing.T) {
121			h, _ := authHarness(t, allowStore(t, tc.content), nil)
122			a := h.client(t)
123			_, ok := h.accepted(t, a.captureWlr(t))
124			if ok != tc.want {
125				t.Fatalf("accepted = %v, want %v", ok, tc.want)
126			}
127		})
128	}
129	t.Run("missing file", func(t *testing.T) {
130		store := captureallow.NewStoreOwnedBy(filepath.Join(t.TempDir(), "absent"), uint32(os.Getuid()), zerowrap.Default())
131		h, _ := authHarness(t, store, nil)
132		if _, ok := h.accepted(t, h.client(t).captureWlr(t)); ok {
133			t.Fatal("missing file: the built-in list must refuse a client it does not list")
134		}
135	})
136	t.Run("no allowlist", func(t *testing.T) {
137		h, _ := authHarness(t, nil, nil)
138		if _, ok := h.accepted(t, h.client(t).captureWlr(t)); ok {
139			t.Fatal("capture allowed without an allowlist: it must fail closed")
140		}
141	})
142}
143
144// devNull is a stand-in pidfd the test can see closed.
145func devNull(t *testing.T) *os.File {
146	t.Helper()
147	f, err := os.Open("/dev/null")
148	if err != nil {
149		t.Fatal(err)
150	}
151	t.Cleanup(func() { _ = f.Close() })
152	return f
153}
154
155// With a peer lookup mock: the pidfd is taken once per client and closed with
156// it; the executable is read again at every wlr frame request and every ext
157// session creation, but not at the frames of an ext session.
158func TestCaptureAllowlistResolvesEachRequest(t *testing.T) {
159	peer := newMockpeerExe(t)
160	pidfd := devNull(t)
161	peer.EXPECT().Pidfd(mock.Anything).RunAndReturn(func(fd int) (*os.File, error) {
162		if fd < 0 {
163			t.Errorf("Pidfd(%d)", fd)
164		}
165		return pidfd, nil
166	}).Once()
167	var exes atomic.Int32
168	peer.EXPECT().Exe(pidfd).RunAndReturn(func(*os.File) (string, int, error) {
169		exes.Add(1)
170		return "/usr/bin/grim", 4242, nil
171	})
172	h, out := authHarness(t, allowStore(t, "/usr/bin/grim\n"), peer)
173	a := h.client(t)
174	for i := 0; i < 3; i++ {
175		req, ok := h.accepted(t, a.captureWlr(t))
176		if !ok {
177			t.Fatal("listed executable refused")
178		}
179		h.complete(t, req, h.inflight(t)-1)
180	}
181	if n := exes.Load(); n != 3 {
182		t.Fatalf("resolved %d times for 3 wlr requests", n)
183	}
184	// One ext session (one resolution), then frames that do not resolve.
185	p := a.captureExt(t)
186	if n := exes.Load(); n != 4 {
187		t.Fatalf("resolved %d times after the ext session", n)
188	}
189	req, ok := h.accepted(t, p)
190	if !ok {
191		t.Fatal("ext frame refused")
192	}
193	h.complete(t, req, 0)
194	a.destroy(t, p)
195	req, ok = h.accepted(t, a.captureExt(t))
196	if !ok {
197		t.Fatal("second ext frame refused")
198	}
199	h.complete(t, req, 0)
200	if n := exes.Load(); n != 4 {
201		t.Fatalf("resolved %d times, ext frames must reuse the session's", n)
202	}
203	if strings.Contains(out.String(), `"message":"capture refused"`) {
204		t.Fatalf("log = %s", out.String())
205	}
206	peers := func() int {
207		n := -1
208		h.s.display.Do(func() { n = len(h.s.peers) })
209		return n
210	}
211	if peers() != 1 {
212		t.Fatalf("peers = %d", peers())
213	}
214	_ = a.c.Close()
215	deadline := time.Now().Add(2 * time.Second)
216	for peers() != 0 {
217		if time.Now().After(deadline) {
218			t.Fatal("peer entry kept after the client went")
219		}
220		time.Sleep(time.Millisecond)
221	}
222	if _, err := pidfd.Stat(); !errors.Is(err, os.ErrClosed) {
223		t.Fatalf("pidfd not closed with the client: %v", err)
224	}
225}
226
227// A program that execs another binary after connecting is judged by the new
228// one at its next request; the refusal is logged once per distinct executable.
229func TestCaptureAllowlistExecAfterConnect(t *testing.T) {
230	peer := newMockpeerExe(t)
231	pidfd := devNull(t)
232	peer.EXPECT().Pidfd(mock.Anything).Return(pidfd, nil).Once()
233	seq := []string{"/usr/bin/grim", "/usr/bin/evil", "/usr/bin/evil", "/usr/bin/grim", "/usr/bin/evil", "/usr/bin/evil2"}
234	i := 0
235	peer.EXPECT().Exe(pidfd).RunAndReturn(func(*os.File) (string, int, error) {
236		e := seq[i]
237		i++
238		return e, 4242, nil
239	})
240	h, out := authHarness(t, allowStore(t, "/usr/bin/grim\n"), peer)
241	a := h.client(t)
242	for n, want := range []bool{true, false, false, true, false, false} {
243		req, ok := h.accepted(t, a.captureWlr(t))
244		if ok != want {
245			t.Fatalf("request %d (%s): accepted = %v", n, seq[n], ok)
246		}
247		if ok {
248			h.complete(t, req, 0)
249		}
250	}
251	log := out.String()
252	if c := strings.Count(log, `"message":"capture refused"`); c != 2 || !strings.Contains(log, `"exe":"/usr/bin/evil"`) || !strings.Contains(log, `"exe":"/usr/bin/evil2"`) {
253		t.Fatalf("want one refusal per distinct executable: %s", log)
254	}
255}
256
257// Without SO_PEERPIDFD a list policy identifies nobody: fail closed, one Warn.
258func TestCaptureAllowlistNoPidfdFailsClosed(t *testing.T) {
259	h, out := authHarness(t, allowStore(t, "/usr/bin/grim\n"), linuxPeerExe{noPidfd: true})
260	a := h.client(t)
261	for i := 0; i < 3; i++ {
262		if _, ok := h.accepted(t, a.captureWlr(t)); ok {
263			t.Fatal("captured without a pidfd")
264		}
265	}
266	log := out.String()
267	if n := strings.Count(log, `"message":"kernel has no SO_PEERPIDFD`); n != 1 || !strings.Contains(log, `"level":"warn"`) {
268		t.Fatalf("want one warning: %s", log)
269	}
270	// `*` needs no identity.
271	h2, _ := authHarness(t, allowStore(t, "*\n"), linuxPeerExe{noPidfd: true})
272	if _, ok := h2.accepted(t, h2.client(t).captureWlr(t)); !ok {
273		t.Fatal("`*` must not need a pidfd")
274	}
275}
276
277func TestCaptureAllowlistUnresolvedExecutableRefused(t *testing.T) {
278	for name, err := range map[string]error{"deleted": errExeDeleted, "unknown": errors.New("no such process")} {
279		t.Run(name, func(t *testing.T) {
280			peer := newMockpeerExe(t)
281			pidfd := devNull(t)
282			peer.EXPECT().Pidfd(mock.Anything).Return(pidfd, nil).Once()
283			peer.EXPECT().Exe(pidfd).Return("", 77, err).Times(2)
284			h, out := authHarness(t, allowStore(t, "/usr/bin/grim\n"), peer)
285			a := h.client(t)
286			for i := 0; i < 2; i++ {
287				if _, ok := h.accepted(t, a.captureWlr(t)); ok {
288					t.Fatal("unresolved executable captured")
289				}
290			}
291			log := out.String()
292			if strings.Count(log, `"message":"capture refused"`) != 1 || !strings.Contains(log, `"pid":77`) || !strings.Contains(log, err.Error()) {
293				t.Fatalf("log = %s", log)
294			}
295		})
296	}
297}
298
299// The check is off for `*`: the executable is never looked up.
300func TestCaptureAllowlistDisabledSkipsLookup(t *testing.T) {
301	peer := newMockpeerExe(t) // any call fails the test
302	h, _ := authHarness(t, allowStore(t, "*\n"), peer)
303	if _, ok := h.accepted(t, h.client(t).captureWlr(t)); !ok {
304		t.Fatal("refused")
305	}
306}
307
308func TestCaptureAllowlistReloadApplies(t *testing.T) {
309	self, _ := os.Executable()
310	path := filepath.Join(t.TempDir(), "capture-allow")
311	if err := os.WriteFile(path, []byte("/usr/bin/grim\n"), 0o644); err != nil {
312		t.Fatal(err)
313	}
314	store := captureallow.NewStoreOwnedBy(path, uint32(os.Getuid()), zerowrap.Default())
315	h, _ := authHarness(t, store, nil)
316	a := h.client(t)
317	if _, ok := h.accepted(t, a.captureWlr(t)); ok {
318		t.Fatal("captured before listing")
319	}
320	// Store.Run is exercised in its own package; here the swap is direct.
321	if err := os.WriteFile(path, []byte(self+"\n"), 0o644); err != nil {
322		t.Fatal(err)
323	}
324	ctx := t.Context()
325	go func() { _ = store.Run(ctx) }()
326	deadline := time.Now().Add(5 * time.Second)
327	for {
328		if req, ok := h.accepted(t, a.captureWlr(t)); ok {
329			h.complete(t, req, 0)
330			return
331		}
332		if time.Now().After(deadline) {
333			t.Fatal("reload never applied")
334		}
335		time.Sleep(20 * time.Millisecond)
336	}
337}
338
339// The real lookup, against a socket whose peer is this process.
340func TestLinuxPeerExeSelf(t *testing.T) {
341	self, err := os.Executable()
342	if err != nil {
343		t.Fatal(err)
344	}
345	fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
346	if err != nil {
347		t.Fatal(err)
348	}
349	defer unix.Close(fds[0])
350	defer unix.Close(fds[1])
351	var l linuxPeerExe
352	pidfd, err := l.Pidfd(fds[0])
353	if errors.Is(err, errNoPidfd) {
354		t.Skip("kernel without SO_PEERPIDFD")
355	}
356	if err != nil {
357		t.Fatal(err)
358	}
359	defer pidfd.Close()
360	exe, pid, err := l.Exe(pidfd)
361	if err != nil {
362		t.Fatal(err)
363	}
364	if exe != self || pid != os.Getpid() {
365		t.Fatalf("Exe = %q, %d; want %q, %d", exe, pid, self, os.Getpid())
366	}
367	if _, err := (linuxPeerExe{noPidfd: true}).Pidfd(fds[0]); !errors.Is(err, errNoPidfd) {
368		t.Fatalf("noPidfd: %v", err)
369	}
370}
371
372// A process whose binary was removed is refused, and a peer that already
373// exited is never resolved from its (possibly recycled) pid.
374func TestLinuxPeerExeDeletedAndExited(t *testing.T) {
375	dir := t.TempDir()
376	path := filepath.Join(dir, "child")
377	src, err := os.ReadFile("/bin/sleep")
378	if err != nil {
379		t.Skip("no /bin/sleep")
380	}
381	if err := os.WriteFile(path, src, 0o755); err != nil {
382		t.Fatal(err)
383	}
384	proc, err := os.StartProcess(path, []string{path, "30"}, &os.ProcAttr{})
385	if err != nil {
386		t.Skip("cannot run a copy of sleep: ", err)
387	}
388	defer func() { _ = proc.Kill(); _, _ = proc.Wait() }()
389	_ = os.Remove(path)
390	// Wait until exec has completed so /proc/<pid>/exe is the copy.
391	deadline := time.Now().Add(2 * time.Second)
392	for {
393		if _, err := readExe(proc.Pid); errors.Is(err, errExeDeleted) {
394			break
395		}
396		if time.Now().After(deadline) {
397			t.Fatal("deleted executable not refused")
398		}
399		time.Sleep(5 * time.Millisecond)
400	}
401	pidfd, err := unix.PidfdOpen(proc.Pid, 0)
402	if err != nil {
403		t.Skip("pidfd_open: ", err)
404	}
405	defer unix.Close(pidfd)
406	if _, _, err := exeOfPidfd(pidfd); !errors.Is(err, errExeDeleted) {
407		t.Fatalf("deleted: %v", err)
408	}
409	_ = proc.Kill()
410	_, _ = proc.Wait()
411	if exe, _, err := exeOfPidfd(pidfd); err == nil {
412		t.Fatalf("exited peer resolved to %q", exe)
413	}
414}
415
416// The exclusion attach asks too: a HUD whose executable is not listed gets
417// `unauthorized` even with the right token, while the recorder that owns the
418// exclusion (listed) is unaffected.
419func TestCaptureAllowlistExclusionAttach(t *testing.T) {
420	peer := newMockpeerExe(t)
421	// The recorder connects first, the HUD second.
422	p1, p2 := devNull(t), devNull(t)
423	peer.EXPECT().Pidfd(mock.Anything).Return(p1, nil).Once()
424	peer.EXPECT().Pidfd(mock.Anything).Return(p2, nil).Once()
425	peer.EXPECT().Exe(p1).Return("/usr/bin/nefercap", 11, nil)
426	peer.EXPECT().Exe(p2).Return("/usr/bin/other", 12, nil)
427	h := newCaptureHarnessWith(t, func(o *Options, _ *Channels) {
428		o.CaptureAllow, o.peer = allowStore(t, "/usr/bin/nefercap\n"), peer
429	})
430	_, _, _, _, token := h.exclusionOwner(t)
431	hd := h.hud(t, ports.LayerOverlay, 2)
432	att := hd.attach(t, token)
433	expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureUnauthorized))
434}
435
436// An ext frame uses the client's latest resolution: after a later wlr request
437// failed to resolve the executable, the frame is refused as unknown, not as
438// "not in allowlist" with an empty path.
439func TestCaptureAllowlistFrameUnknownExecutable(t *testing.T) {
440	peer := newMockpeerExe(t)
441	pidfd := devNull(t)
442	peer.EXPECT().Pidfd(mock.Anything).Return(pidfd, nil).Once()
443	var calls atomic.Int32
444	peer.EXPECT().Exe(pidfd).RunAndReturn(func(*os.File) (string, int, error) {
445		if calls.Add(1) == 2 {
446			return "", 55, errors.New("peer exited")
447		}
448		return "/usr/bin/grim", 55, nil
449	})
450	h, out := authHarness(t, allowStore(t, "/usr/bin/grim\n"), peer)
451	a := h.client(t)
452	a.extMgr, a.session, _ = captureTestExt(t, a.c, a.output) // resolution 1: listed
453	if _, ok := h.accepted(t, a.captureWlr(t)); ok {          // resolution 2: fails
454		t.Fatal("unresolved wlr request captured")
455	}
456	frame, ev := a.newFrame()
457	requestProtocol(t, a.c, a.session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
458	requestProtocol(t, a.c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, a.buf)
459	requestProtocol(t, a.c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
460	if err := a.c.Roundtrip(); err != nil {
461		t.Fatal(err)
462	}
463	if _, ok := h.accepted(t, pendingCapture{frame: frame, events: ev, failed: uint16(ext.ExtImageCopyCaptureFrameV1EventFailed), ext: true}); ok {
464		t.Fatal("ext frame captured after a failed resolution")
465	}
466	if !strings.Contains(out.String(), `"reason":"executable unknown: peer exited"`) {
467		t.Fatalf("log = %s", out.String())
468	}
469}

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