main f2d166b

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

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