1package screencast
2
3import (
4 "errors"
5 "os"
6 "slices"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/bnema/neferwl/internal/adapters/captureallow"
12 "github.com/bnema/neferwl/internal/ports"
13 "github.com/bnema/zerobus"
14)
15
16func testInventory() ports.CastInventory {
17 return ports.CastInventory{
18 Focused: "HEADLESS-1",
19 Outputs: []ports.CastOutput{
20 {Info: ports.OutputInfo{Name: "HEADLESS-2", Make: "Acme", Model: "Two", Width: 32, Height: 32, RefreshMilli: 30000}, Modes: []ports.OutputMode{{Width: 32, Height: 32, RefreshMilli: 30000, Preferred: true}}, Current: 0, X: 64, Width: 32, Height: 32, Scale: 1},
21 {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32, RefreshMilli: 60000}, Modes: []ports.OutputMode{{Width: 64, Height: 32, RefreshMilli: 60000, Preferred: true}, {Width: 32, Height: 16, RefreshMilli: 60000}}, Current: 0, Width: 64, Height: 32, Scale: 1},
22 },
23 Windows: []ports.CastWindow{{ID: 7, AppID: "org.example.App", Title: "Hello"}, {ID: 3, AppID: "foot", Title: "sh"}},
24 }
25}
26
27// ready starts the service, waits for its names and publishes the inventory.
28func ready(t *testing.T, o harnessOpts) (*svcHarness, *zerobus.Conn) {
29 t.Helper()
30 h := newSvcHarness(t, o)
31 c := h.client()
32 for _, n := range []string{nameDisplayConfig, nameIntrospect, nameScreenshot} {
33 h.waitName(c, n)
34 }
35 if !o.noPW {
36 h.waitName(c, nameScreenCast)
37 }
38 h.svc.Inventory(testInventory())
39 // The inventory is applied by the owner before it serves the next call
40 // that needs it: a round trip through the service orders them.
41 deadline := time.Now().Add(5 * time.Second)
42 for time.Now().Before(deadline) {
43 _, m := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
44 if m != nil && m.Signature == "a{ta{sv}}" {
45 r := m.Body()
46 if end := r.Array('{'); r.More(end) {
47 return h, c
48 }
49 }
50 time.Sleep(5 * time.Millisecond)
51 }
52 t.Fatal("inventory not applied")
53 return h, c
54}
55
56func createSession(t *testing.T, c *zerobus.Conn) string {
57 t.Helper()
58 name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) {
59 a := e.BeginArray('{')
60 e.EndArray(a)
61 })
62 if name != "" {
63 t.Fatalf("CreateSession: %s", name)
64 }
65 return strings.Clone(m.Body().ObjectPath())
66}
67
68func recordMonitor(c *zerobus.Conn, sess, connector string) (string, string) {
69 name, m := callErr(c, nameScreenCast, sess, ifaceSession, "RecordMonitor", "sa{sv}", func(e *zerobus.Encoder) {
70 e.Str(connector)
71 a := e.BeginArray('{')
72 e.Struct()
73 e.Str("cursor-mode")
74 e.Variant("u")
75 e.Uint32(1)
76 e.EndArray(a)
77 })
78 if name != "" {
79 return name, ""
80 }
81 return "", strings.Clone(m.Body().ObjectPath())
82}
83
84// Every method of the contract answers a caller on the allowlist.
85func TestMethodMatrix(t *testing.T) {
86 h, c := ready(t, harnessOpts{})
87 _ = h
88 sess := createSession(t, c)
89 if sess != "/org/gnome/Mutter/ScreenCast/Session/u1" {
90 t.Fatalf("session path %q", sess)
91 }
92
93 // Properties of the root object.
94 name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") })
95 if name != "" || m.Signature != "v" {
96 t.Fatalf("Get Version: %s %v", name, m)
97 }
98 r := m.Body()
99 if sig := r.Variant(); sig != "i" || r.Int32() != 4 {
100 t.Fatal("Version is not 4")
101 }
102 if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Nope") }); name != errUnknownProperty {
103 t.Fatalf("unknown property: %q", name)
104 }
105 if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Set", "ssv", func(e *zerobus.Encoder) {
106 e.Str(ifaceScreenCast)
107 e.Str("Version")
108 e.Variant("i")
109 e.Int32(1)
110 }); name != errPropertyReadOnly {
111 t.Fatalf("Set: %q", name)
112 }
113 name, m = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) })
114 if name != "" || m.Signature != "a{sv}" {
115 t.Fatalf("GetAll: %s", name)
116 }
117
118 // Streams: the monitor, the window, and what is not supported.
119 stream, got := "", ""
120 if name, stream = recordMonitor(c, sess, "HEADLESS-1"); name != "" {
121 t.Fatalf("RecordMonitor: %s", name)
122 }
123 if stream != "/org/gnome/Mutter/ScreenCast/Stream/u1" {
124 t.Fatalf("stream path %q", stream)
125 }
126 if name, _ = recordMonitor(c, sess, "NOPE-1"); name != errInvalidArgs {
127 t.Fatalf("RecordMonitor of a missing monitor: %q", name)
128 }
129 name, m = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
130 a := e.BeginArray('{')
131 e.Struct()
132 e.Str("window-id")
133 e.Variant("t")
134 e.Uint64(7)
135 e.EndArray(a)
136 })
137 if name != "" {
138 t.Fatalf("RecordWindow: %s", name)
139 }
140 got = strings.Clone(m.Body().ObjectPath())
141 if got != "/org/gnome/Mutter/ScreenCast/Stream/u2" {
142 t.Fatalf("window stream path %q", got)
143 }
144 name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
145 a := e.BeginArray('{')
146 e.Struct()
147 e.Str("window-id")
148 e.Variant("t")
149 e.Uint64(99)
150 e.EndArray(a)
151 })
152 if name != errInvalidArgs {
153 t.Fatalf("RecordWindow of a closed window: %q", name)
154 }
155 if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordArea", "iiiia{sv}", func(e *zerobus.Encoder) {
156 e.Int32(0)
157 e.Int32(0)
158 e.Int32(1)
159 e.Int32(1)
160 e.EndArray(e.BeginArray('{'))
161 }); name == "" {
162 t.Fatal("RecordArea is not supported")
163 }
164
165 // The stream answers GetAll with its parameters before Start.
166 name, m = callErr(c, nameScreenCast, stream, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceStream) })
167 if name != "" {
168 t.Fatalf("stream GetAll: %s", name)
169 }
170 r = m.Body()
171 end := r.Array('{')
172 r.Struct()
173 if r.Str() != "Parameters" || r.Variant() != "a{sv}" {
174 t.Fatal("no Parameters")
175 }
176 pend := r.Array('{')
177 seen := map[string][2]int32{}
178 for r.More(pend) {
179 r.Struct()
180 key := r.Str()
181 if sig := r.Variant(); sig != "(ii)" {
182 t.Fatalf("%s is %s", key, sig)
183 }
184 r.Struct()
185 seen[key] = [2]int32{r.Int32(), r.Int32()}
186 }
187 _ = end
188 if seen["position"] != [2]int32{0, 0} || seen["size"] != [2]int32{64, 32} {
189 t.Fatalf("parameters %v", seen)
190 }
191
192 // Introspect and DisplayConfig properties.
193 name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceIntrospect); e.Str("version") })
194 if name != "" {
195 t.Fatalf("version: %s", name)
196 }
197 r = m.Body()
198 if r.Variant() != "u" || r.Uint32() != 1 {
199 t.Fatal("Introspect version is not 1")
200 }
201 if name, _ = callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "ApplyMonitorsConfig", "", nil); name == "" {
202 t.Fatal("ApplyMonitorsConfig is not supported")
203 }
204 if name, _ = callErr(c, nameScreenCast, "/org/gnome/Mutter/ScreenCast/Session/u99", ifaceSession, "Stop", "", nil); name != errUnknownObject {
205 t.Fatalf("unknown session: %q", name)
206 }
207 if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
208 t.Fatalf("Stop: %q", name)
209 }
210 if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != errUnknownObject {
211 t.Fatalf("Stop twice: %q", name)
212 }
213}
214
215// A signal from the session reaches only its owner: Stop is answered, then
216// the owner hears Closed.
217func TestStopSignalsClosedToOwner(t *testing.T) {
218 h, c := ready(t, harnessOpts{})
219 other := h.client()
220 rule := "type='signal',interface='" + ifaceSession + "'"
221 for _, x := range []*zerobus.Conn{c, other} {
222 if err := x.AddMatch(rule); err != nil {
223 t.Fatal(err)
224 }
225 }
226 sess := createSession(t, c)
227 if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
228 t.Fatal(name)
229 }
230 m := readUntil(t, c, "Closed")
231 if m.Path != sess || m.Type != zerobus.TypeSignal {
232 t.Fatalf("closed %+v", m)
233 }
234 // The other client would have heard it before the answer of this call.
235 other.Handle = func(m *zerobus.Message) {
236 if m.Member == "Closed" {
237 t.Error("Closed reached a client that is not the owner")
238 }
239 }
240 other.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetId", "")
241 if _, err := other.Call(); err != nil {
242 t.Fatal(err)
243 }
244}
245
246func readUntil(t *testing.T, c *zerobus.Conn, member string) *zerobus.Message {
247 t.Helper()
248 for {
249 m, err := c.ReadMessage()
250 if err != nil {
251 t.Fatal(err)
252 }
253 if m.Member == member {
254 return m
255 }
256 }
257}
258
259// A caller whose executable is not on the allowlist is refused by every
260// object and every method, and nothing is created.
261func TestDisallowedCallerGetsAccessDenied(t *testing.T) {
262 h, c := ready(t, harnessOpts{})
263 h.peers.exe.Store("/usr/bin/evil")
264 for _, tc := range []struct {
265 dest, path, iface, member, sig string
266 body func(*zerobus.Encoder)
267 }{
268 {nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }},
269 {nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) }},
270 {nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") }},
271 {nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil},
272 {nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil},
273 {nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "", nil},
274 {nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) { e.Bool(false); e.Bool(false); e.Str("x") }},
275 {nameIntrospect, pathIntrospect, ifacePeer, "Ping", "", nil},
276 } {
277 if name, _ := callErr(c, tc.dest, tc.path, tc.iface, tc.member, tc.sig, tc.body); name != errAccessDenied {
278 t.Errorf("%s.%s: %q", tc.iface, tc.member, name)
279 }
280 }
281 select {
282 case cmd := <-h.cmds:
283 t.Fatalf("command sent for a refused caller: %+v", cmd)
284 default:
285 }
286}
287
288// A policy that closes is applied at once: the cache keeps the executable,
289// not the verdict.
290func TestPolicyChangeApplies(t *testing.T) {
291 h := newSvcHarness(t, harnessOpts{allow: captureallow.Closed()})
292 c := h.client()
293 h.waitName(c, nameIntrospect)
294 if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
295 t.Fatalf("closed policy: %q", name)
296 }
297}
298
299// The executable is read again where it matters: an exec after the first
300// check is caught at CreateSession.
301func TestExecAfterFirstCheckIsCaught(t *testing.T) {
302 h, c := ready(t, harnessOpts{})
303 if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" {
304 t.Fatalf("first call: %q", name)
305 }
306 h.peers.exe.Store("/usr/bin/evil")
307 // Not a privileged entry point: the cached executable still serves it.
308 if name, _ := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil); name != "" {
309 t.Fatalf("cached call: %q", name)
310 }
311 name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) })
312 if name != errAccessDenied {
313 t.Fatalf("CreateSession after exec: %q", name)
314 }
315}
316
317// A pidfd that names another process than the one the bus named is refused.
318func TestPidMismatchIsRefused(t *testing.T) {
319 h := newSvcHarness(t, harnessOpts{})
320 h.peers.exePid = func(open int) int { return open + 1 }
321 c := h.client()
322 h.waitName(c, nameIntrospect)
323 if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
324 t.Fatalf("pid mismatch: %q", name)
325 }
326}
327
328func TestPidfdOpenFailureIsRefused(t *testing.T) {
329 h := newSvcHarness(t, harnessOpts{})
330 h.peers.openErr = errors.New("no such process")
331 c := h.client()
332 h.waitName(c, nameIntrospect)
333 if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
334 t.Fatalf("pidfd_open failure: %q", name)
335 }
336}
337
338// The session and its streams answer only the unique name that created it.
339func TestNonOwnerIsDenied(t *testing.T) {
340 h, c := ready(t, harnessOpts{})
341 other := h.client()
342 sess := createSession(t, c)
343 _, stream := recordMonitor(c, sess, "HEADLESS-1")
344 for _, tc := range []struct{ path, iface, member, sig string }{
345 {sess, ifaceSession, "Stop", ""},
346 {sess, ifaceSession, "Start", ""},
347 {sess, ifaceProps, "GetAll", "s"},
348 {stream, ifaceProps, "GetAll", "s"},
349 {stream, ifaceProps, "Get", "ss"},
350 } {
351 name, _ := callErr(other, nameScreenCast, tc.path, tc.iface, tc.member, tc.sig, func(e *zerobus.Encoder) {
352 switch tc.sig {
353 case "s":
354 e.Str(ifaceSession)
355 case "ss":
356 e.Str(ifaceStream)
357 e.Str("Parameters")
358 }
359 })
360 if name != errAccessDenied {
361 t.Errorf("%s %s: %q", tc.path, tc.member, name)
362 }
363 }
364 if name, _ := recordMonitor(other, sess, "HEADLESS-1"); name != errAccessDenied {
365 t.Errorf("RecordMonitor by a non-owner: %q", name)
366 }
367 // The owner is unaffected.
368 if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
369 t.Fatalf("owner Stop: %q", name)
370 }
371}
372
373// Requests wait in a fixed table while their sender is checked: when it is
374// full, the next one gets LimitsExceeded, and the parked ones are answered
375// once the check ends.
376func TestPendingTableFull(t *testing.T) {
377 h := newSvcHarness(t, harnessOpts{holdOp: true})
378 c := h.client()
379 h.waitName(c, nameIntrospect)
380 serials := make([]uint32, 0, maxPending+1)
381 for range maxPending + 1 {
382 c.NewCall(nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "")
383 serial, err := c.Send()
384 if err != nil {
385 t.Fatal(err)
386 }
387 serials = append(serials, serial)
388 }
389 m, err := c.ReadMessage()
390 if err != nil {
391 t.Fatal(err)
392 }
393 if m.Type != zerobus.TypeError || m.ReplySerial != serials[maxPending] || m.ErrorName != errLimits {
394 t.Fatalf("17th request: type %d error %q reply %d", m.Type, m.ErrorName, m.ReplySerial)
395 }
396 close(h.peers.openGo)
397 for range maxPending {
398 m, err := c.ReadMessage()
399 if err != nil {
400 t.Fatal(err)
401 }
402 if m.Type != zerobus.TypeMethodReturn {
403 t.Fatalf("parked request: type %d %q", m.Type, m.ErrorName)
404 }
405 }
406}
407
408// Closing the owner's connection stops its session: its slot is freed, so
409// a new session starts again from the table.
410func TestOwnerVanishFreesSessions(t *testing.T) {
411 h, c := ready(t, harnessOpts{})
412 owner := h.client()
413 for range maxSessions {
414 createSession(t, owner)
415 }
416 if name, _ := callErr(owner, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }); name != errLimits {
417 t.Fatalf("over the limit: %q", name)
418 }
419 _ = owner.Close()
420 deadline := time.Now().Add(5 * time.Second)
421 for {
422 name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) })
423 if name == "" {
424 return
425 }
426 if time.Now().After(deadline) {
427 t.Fatalf("sessions of a vanished owner stay: %q\n%s", name, h.log.String())
428 }
429 time.Sleep(10 * time.Millisecond)
430 }
431}
432
433// The remote desktop key is refused, and a service without PipeWire does not
434// take the ScreenCast name.
435func TestRemoteDesktopRefusedAndNoPipeWire(t *testing.T) {
436 _, c := ready(t, harnessOpts{})
437 name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) {
438 a := e.BeginArray('{')
439 e.Struct()
440 e.Str("remote-desktop-session-id")
441 e.Variant("s")
442 e.Str("x")
443 e.EndArray(a)
444 })
445 if name != errNotSupported {
446 t.Fatalf("remote desktop: %q", name)
447 }
448
449 h := newSvcHarness(t, harnessOpts{noPW: true})
450 c2 := h.client()
451 h.waitName(c2, nameIntrospect)
452 e := c2.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
453 e.Str(nameScreenCast)
454 m, err := c2.Call()
455 if err != nil || m.Body().Bool() {
456 t.Fatalf("ScreenCast name taken without PipeWire (%v)", err)
457 }
458}
459
460// The encoding of GetCurrentState and GetWindows from an inventory.
461func TestDisplayConfigAndWindows(t *testing.T) {
462 _, c := ready(t, harnessOpts{})
463 name, m := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil)
464 if name != "" {
465 t.Fatal(name)
466 }
467 r := m.Body()
468 if r.Uint32() != 0 {
469 t.Fatal("serial")
470 }
471 type mon struct {
472 connector string
473 current int
474 modes int
475 display string
476 builtin bool
477 }
478 var mons []mon
479 end := r.Array('(')
480 for r.More(end) {
481 r.Struct()
482 r.Struct()
483 x := mon{connector: r.Str()}
484 r.Str()
485 r.Str()
486 r.Str()
487 mend := r.Array('(')
488 for r.More(mend) {
489 r.Struct()
490 r.Str()
491 r.Int32()
492 r.Int32()
493 r.Float64()
494 r.Float64()
495 send := r.Array('d')
496 for r.More(send) {
497 r.Float64()
498 }
499 pend := r.Array('{')
500 x.modes++
501 for r.More(pend) {
502 r.Struct()
503 key := r.Str()
504 sig := r.Variant()
505 if key == "is-current" && r.Bool() {
506 x.current++
507 } else if sig == "b" {
508 r.Bool()
509 }
510 }
511 }
512 pend := r.Array('{')
513 for r.More(pend) {
514 r.Struct()
515 key := r.Str()
516 switch r.Variant() {
517 case "s":
518 if v := r.Str(); key == "display-name" {
519 x.display = v
520 }
521 case "b":
522 x.builtin = r.Bool()
523 }
524 }
525 mons = append(mons, x)
526 }
527 logicals := 0
528 lend := r.Array('(')
529 for r.More(lend) {
530 r.Struct()
531 r.Int32()
532 r.Int32()
533 r.Float64()
534 r.Uint32()
535 r.Bool()
536 mend := r.Array('(')
537 for r.More(mend) {
538 r.Struct()
539 r.Str()
540 r.Str()
541 r.Str()
542 r.Str()
543 }
544 pend := r.Array('{')
545 r.SkipTo(pend)
546 logicals++
547 }
548 pend := r.Array('{')
549 r.Struct()
550 if r.Str() != "layout-mode" || r.Variant() != "u" || r.Uint32() != 1 {
551 t.Fatal("layout-mode")
552 }
553 _ = pend
554 if !r.Done() {
555 t.Fatalf("trailing data: %v", r.Err())
556 }
557 if len(mons) != 2 || logicals != 2 || mons[0].connector != "HEADLESS-1" || mons[1].connector != "HEADLESS-2" {
558 t.Fatalf("monitors %+v logical %d", mons, logicals)
559 }
560 for _, x := range mons {
561 if x.current != 1 {
562 t.Errorf("%s has %d current modes", x.connector, x.current)
563 }
564 }
565 if mons[0].modes != 2 || mons[1].display != "Acme Two" || mons[0].display != "HEADLESS-1" {
566 t.Fatalf("monitors %+v", mons)
567 }
568
569 name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
570 if name != "" {
571 t.Fatal(name)
572 }
573 r = m.Body()
574 got := map[uint64][2]string{}
575 wend := r.Array('{')
576 for r.More(wend) {
577 r.Struct()
578 id := r.Uint64()
579 var v [2]string
580 pend := r.Array('{')
581 for r.More(pend) {
582 r.Struct()
583 key := r.Str()
584 r.Variant()
585 val := r.Str()
586 switch key {
587 case "app-id":
588 v[0] = val
589 case "title":
590 v[1] = val
591 }
592 }
593 got[id] = v
594 }
595 if len(got) != 2 || got[7] != [2]string{"org.example.App", "Hello"} || got[3] != [2]string{"foot", "sh"} {
596 t.Fatalf("windows %v", got)
597 }
598}
599
600// MonitorsChanged and WindowsChanged are directed: a peer that was refused
601// gets nothing, even with a match rule for them; an allowed one gets them,
602// and a peer that left the bus is forgotten.
603func TestInventorySignalsGoToAllowedPeersOnly(t *testing.T) {
604 h := newSvcHarness(t, harnessOpts{})
605 h.peers.exeFile = func(n int) string {
606 if n == 0 || n == 2 {
607 return "/usr/bin/grim"
608 }
609 return "/usr/bin/evil"
610 }
611 allowed, denied, gone := h.client(), h.client(), h.client()
612 for _, n := range []string{nameDisplayConfig, nameIntrospect} {
613 h.waitName(allowed, n)
614 }
615 rule := "type='signal',path='" + pathIntrospect + "'"
616 rule2 := "type='signal',path='" + pathDisplayConfig + "'"
617 for _, x := range []*zerobus.Conn{allowed, denied, gone} {
618 for _, r := range []string{rule, rule2} {
619 if err := x.AddMatch(r); err != nil {
620 t.Fatal(err)
621 }
622 }
623 }
624 // Check in order: allowed (pidfd 0), denied (1), gone (2).
625 for i, x := range []*zerobus.Conn{allowed, denied, gone} {
626 want := ""
627 if i == 1 {
628 want = errAccessDenied
629 }
630 if name, _ := callErr(x, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != want {
631 t.Fatalf("peer %d: %q, want %q", i, name, want)
632 }
633 }
634 _ = gone.Close()
635 // The first inventory is not a change. Wait until the owner applied it
636 // (a published inventory replaces an unread one), then publish a change.
637 h.svc.Inventory(testInventory())
638 deadline := time.Now().Add(5 * time.Second)
639 for {
640 _, m := callErr(allowed, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
641 if m != nil && m.Signature == "a{ta{sv}}" {
642 r := m.Body()
643 if end := r.Array('{'); r.More(end) {
644 break
645 }
646 }
647 if time.Now().After(deadline) {
648 t.Fatal("inventory not applied")
649 }
650 time.Sleep(5 * time.Millisecond)
651 }
652 changed := testInventory()
653 changed.Windows = append(changed.Windows, ports.CastWindow{ID: 9, AppID: "x", Title: "new"})
654 changed.Outputs[0].X = 100
655 h.svc.Inventory(changed)
656 got := make(chan string, 8)
657 go func() {
658 for {
659 m, err := allowed.ReadMessage()
660 if err != nil {
661 return
662 }
663 if m.Type == zerobus.TypeSignal && (m.Member == "WindowsChanged" || m.Member == "MonitorsChanged") {
664 got <- m.Member
665 }
666 }
667 }()
668 seen := map[string]bool{}
669 for len(seen) < 2 {
670 select {
671 case m := <-got:
672 seen[m] = true
673 case <-time.After(5 * time.Second):
674 t.Fatalf("allowed peer heard %v", seen)
675 }
676 }
677 // The service's owner goroutine answers in order: a call that it
678 // answers (AccessDenied) comes after any signal it had wrongly directed
679 // to this connection. The handler is active during the call.
680 denied.Handle = func(m *zerobus.Message) {
681 if m.Member == "WindowsChanged" || m.Member == "MonitorsChanged" {
682 t.Errorf("refused peer got %s", m.Member)
683 }
684 }
685 if name, _ := callErr(denied, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
686 t.Fatalf("refused peer: %q", name)
687 }
688}
689
690// pidfdClosed reports whether the file was closed.
691func pidfdClosed(f *os.File) bool {
692 _, err := f.Stat()
693 return errors.Is(err, os.ErrClosed)
694}
695
696// Refused callers are not cached: more of them than the cache holds still
697// leave room for the allowed peer, and their pidfds are closed at once.
698func TestDeniedCallersDoNotExhaustTheCache(t *testing.T) {
699 const denied = maxCallers + 8
700 // One pidfd per check: the first peer is checked twice.
701 const deniedChecks = denied + 1
702 h := newSvcHarness(t, harnessOpts{})
703 h.peers.exeFile = func(n int) string {
704 if n < deniedChecks {
705 return "/usr/bin/evil"
706 }
707 return "/usr/bin/grim"
708 }
709 c0 := h.client()
710 h.waitName(c0, nameIntrospect)
711 for i := range denied {
712 x := h.client()
713 if name, _ := callErr(x, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
714 t.Fatalf("denied peer %d: %q", i, name)
715 }
716 // The same peer asking again is checked again, never LimitsExceeded.
717 if i == 0 {
718 if name, _ := callErr(x, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
719 t.Fatalf("denied peer again: %q", name)
720 }
721 }
722 }
723 good := h.client()
724 if name, _ := callErr(good, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" {
725 t.Fatalf("allowed peer after %d denied: %q", denied, name)
726 }
727 h.peers.mu.Lock()
728 opened := slices.Clone(h.peers.opened)
729 h.peers.mu.Unlock()
730 for i, f := range opened[:deniedChecks] {
731 if !pidfdClosed(f) {
732 t.Fatalf("pidfd of denied peer %d still open", i)
733 }
734 }
735 if pidfdClosed(opened[len(opened)-1]) {
736 t.Fatal("pidfd of the allowed peer closed while it is cached")
737 }
738}
739
740// The pidfds of the cached callers are closed when the bus is lost.
741func TestBusLossClosesCallerPidfds(t *testing.T) {
742 h := newSvcHarness(t, harnessOpts{})
743 c := h.client()
744 h.waitName(c, nameIntrospect)
745 if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" {
746 t.Fatal(name)
747 }
748 h.peers.mu.Lock()
749 opened := slices.Clone(h.peers.opened)
750 h.peers.mu.Unlock()
751 if len(opened) != 1 || pidfdClosed(opened[0]) {
752 t.Fatalf("%d pidfds, want one open", len(opened))
753 }
754 h.killBus()
755 deadline := time.Now().Add(5 * time.Second)
756 for !pidfdClosed(opened[0]) {
757 if time.Now().After(deadline) {
758 t.Fatal("pidfd of a cached caller survives the bus loss")
759 }
760 time.Sleep(5 * time.Millisecond)
761 }
762}