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}