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}