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 // Drive admission on this goroutine, delaying the caller-check transition
378 // rather than blocking Open on the service owner. A PID reply may arrive
379 // before all calls do; blocking Open then prevents the owner filling the
380 // table or answering the overflow at all.
381 addr, _ := startBus(t)
382 c, err := zerobus.Dial(addr)
383 if err != nil {
384 t.Fatal(err)
385 }
386 t.Cleanup(func() { _ = c.Close() })
387 server, err := zerobus.Dial(addr)
388 if err != nil {
389 t.Fatal(err)
390 }
391 t.Cleanup(func() { _ = server.Close() })
392 s := New(captureallow.Builtin, nil, testLog(&logBuf{}))
393 s.conn = server
394 peer := &s.callers[0]
395 *peer = caller{state: callerPid, name: c.UniqueName()}
396 for i := range maxPending + 1 {
397 s.admit(&request{sender: c.UniqueName(), serial: uint32(i + 1), obj: objIntrospect, method: mGetWindows})
398 }
399 // Closing the real connection bounds reads without concurrently using its
400 // encoder/decoder. No reply means a failed test, not the CI-wide timeout.
401 timer := time.AfterFunc(5*time.Second, func() { _ = c.Close() })
402 defer timer.Stop()
403 m, err := c.ReadMessage()
404 for err == nil && m.Type == zerobus.TypeSignal {
405 m, err = c.ReadMessage()
406 }
407 if err != nil {
408 t.Fatal(err)
409 }
410 if m.Type != zerobus.TypeError || m.ReplySerial != maxPending+1 || m.ErrorName != errLimits {
411 t.Fatalf("17th request: type %d error %q reply %d", m.Type, m.ErrorName, m.ReplySerial)
412 }
413 peer.state, peer.exe = callerKnown, "/usr/bin/grim"
414 s.flush(peer)
415 for range maxPending {
416 m, err := c.ReadMessage()
417 if err != nil {
418 t.Fatal(err)
419 }
420 if m.Type != zerobus.TypeMethodReturn {
421 t.Fatalf("parked request: type %d %q", m.Type, m.ErrorName)
422 }
423 }
424}
425
426// Closing the owner's connection stops its session: its slot is freed, so
427// a new session starts again from the table.
428func TestOwnerVanishFreesSessions(t *testing.T) {
429 h, c := ready(t, harnessOpts{})
430 owner := h.client()
431 for range maxSessions {
432 createSession(t, owner)
433 }
434 if name, _ := callErr(owner, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }); name != errLimits {
435 t.Fatalf("over the limit: %q", name)
436 }
437 _ = owner.Close()
438 deadline := time.Now().Add(5 * time.Second)
439 for {
440 name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) })
441 if name == "" {
442 return
443 }
444 if time.Now().After(deadline) {
445 t.Fatalf("sessions of a vanished owner stay: %q\n%s", name, h.log.String())
446 }
447 time.Sleep(10 * time.Millisecond)
448 }
449}
450
451// The remote desktop key is refused, and a service without PipeWire does not
452// take the ScreenCast name.
453func TestRemoteDesktopRefusedAndNoPipeWire(t *testing.T) {
454 _, c := ready(t, harnessOpts{})
455 name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) {
456 a := e.BeginArray('{')
457 e.Struct()
458 e.Str("remote-desktop-session-id")
459 e.Variant("s")
460 e.Str("x")
461 e.EndArray(a)
462 })
463 if name != errNotSupported {
464 t.Fatalf("remote desktop: %q", name)
465 }
466
467 h := newSvcHarness(t, harnessOpts{noPW: true})
468 c2 := h.client()
469 h.waitName(c2, nameIntrospect)
470 e := c2.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
471 e.Str(nameScreenCast)
472 m, err := c2.Call()
473 if err != nil || m.Body().Bool() {
474 t.Fatalf("ScreenCast name taken without PipeWire (%v)", err)
475 }
476}
477
478// The encoding of GetCurrentState and GetWindows from an inventory.
479func TestDisplayConfigAndWindows(t *testing.T) {
480 _, c := ready(t, harnessOpts{})
481 name, m := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil)
482 if name != "" {
483 t.Fatal(name)
484 }
485 r := m.Body()
486 if r.Uint32() != 0 {
487 t.Fatal("serial")
488 }
489 type mon struct {
490 connector string
491 current int
492 modes int
493 display string
494 builtin bool
495 }
496 var mons []mon
497 end := r.Array('(')
498 for r.More(end) {
499 r.Struct()
500 r.Struct()
501 x := mon{connector: r.Str()}
502 r.Str()
503 r.Str()
504 r.Str()
505 mend := r.Array('(')
506 for r.More(mend) {
507 r.Struct()
508 r.Str()
509 r.Int32()
510 r.Int32()
511 r.Float64()
512 r.Float64()
513 send := r.Array('d')
514 for r.More(send) {
515 r.Float64()
516 }
517 pend := r.Array('{')
518 x.modes++
519 for r.More(pend) {
520 r.Struct()
521 key := r.Str()
522 sig := r.Variant()
523 if key == "is-current" && r.Bool() {
524 x.current++
525 } else if sig == "b" {
526 r.Bool()
527 }
528 }
529 }
530 pend := r.Array('{')
531 for r.More(pend) {
532 r.Struct()
533 key := r.Str()
534 switch r.Variant() {
535 case "s":
536 if v := r.Str(); key == "display-name" {
537 x.display = v
538 }
539 case "b":
540 x.builtin = r.Bool()
541 }
542 }
543 mons = append(mons, x)
544 }
545 logicals := 0
546 lend := r.Array('(')
547 for r.More(lend) {
548 r.Struct()
549 r.Int32()
550 r.Int32()
551 r.Float64()
552 r.Uint32()
553 r.Bool()
554 mend := r.Array('(')
555 for r.More(mend) {
556 r.Struct()
557 r.Str()
558 r.Str()
559 r.Str()
560 r.Str()
561 }
562 pend := r.Array('{')
563 r.SkipTo(pend)
564 logicals++
565 }
566 pend := r.Array('{')
567 r.Struct()
568 if r.Str() != "layout-mode" || r.Variant() != "u" || r.Uint32() != 1 {
569 t.Fatal("layout-mode")
570 }
571 _ = pend
572 if !r.Done() {
573 t.Fatalf("trailing data: %v", r.Err())
574 }
575 if len(mons) != 2 || logicals != 2 || mons[0].connector != "HEADLESS-1" || mons[1].connector != "HEADLESS-2" {
576 t.Fatalf("monitors %+v logical %d", mons, logicals)
577 }
578 for _, x := range mons {
579 if x.current != 1 {
580 t.Errorf("%s has %d current modes", x.connector, x.current)
581 }
582 }
583 if mons[0].modes != 2 || mons[1].display != "Acme Two" || mons[0].display != "HEADLESS-1" {
584 t.Fatalf("monitors %+v", mons)
585 }
586
587 name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
588 if name != "" {
589 t.Fatal(name)
590 }
591 r = m.Body()
592 got := map[uint64][2]string{}
593 wend := r.Array('{')
594 for r.More(wend) {
595 r.Struct()
596 id := r.Uint64()
597 var v [2]string
598 pend := r.Array('{')
599 for r.More(pend) {
600 r.Struct()
601 key := r.Str()
602 r.Variant()
603 val := r.Str()
604 switch key {
605 case "app-id":
606 v[0] = val
607 case "title":
608 v[1] = val
609 }
610 }
611 got[id] = v
612 }
613 if len(got) != 2 || got[7] != [2]string{"org.example.App", "Hello"} || got[3] != [2]string{"foot", "sh"} {
614 t.Fatalf("windows %v", got)
615 }
616}
617
618// MonitorsChanged and WindowsChanged are directed: a peer that was refused
619// gets nothing, even with a match rule for them; an allowed one gets them,
620// and a peer that left the bus is forgotten.
621func TestInventorySignalsGoToAllowedPeersOnly(t *testing.T) {
622 h := newSvcHarness(t, harnessOpts{})
623 h.peers.exeFile = func(n int) string {
624 if n == 0 || n == 2 {
625 return "/usr/bin/grim"
626 }
627 return "/usr/bin/evil"
628 }
629 allowed, denied, gone := h.client(), h.client(), h.client()
630 for _, n := range []string{nameDisplayConfig, nameIntrospect} {
631 h.waitName(allowed, n)
632 }
633 rule := "type='signal',path='" + pathIntrospect + "'"
634 rule2 := "type='signal',path='" + pathDisplayConfig + "'"
635 for _, x := range []*zerobus.Conn{allowed, denied, gone} {
636 for _, r := range []string{rule, rule2} {
637 if err := x.AddMatch(r); err != nil {
638 t.Fatal(err)
639 }
640 }
641 }
642 // Check in order: allowed (pidfd 0), denied (1), gone (2).
643 for i, x := range []*zerobus.Conn{allowed, denied, gone} {
644 want := ""
645 if i == 1 {
646 want = errAccessDenied
647 }
648 if name, _ := callErr(x, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != want {
649 t.Fatalf("peer %d: %q, want %q", i, name, want)
650 }
651 }
652 _ = gone.Close()
653 // The first inventory is not a change. Wait until the owner applied it
654 // (a published inventory replaces an unread one), then publish a change.
655 h.svc.Inventory(testInventory())
656 deadline := time.Now().Add(5 * time.Second)
657 for {
658 _, m := callErr(allowed, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
659 if m != nil && m.Signature == "a{ta{sv}}" {
660 r := m.Body()
661 if end := r.Array('{'); r.More(end) {
662 break
663 }
664 }
665 if time.Now().After(deadline) {
666 t.Fatal("inventory not applied")
667 }
668 time.Sleep(5 * time.Millisecond)
669 }
670 changed := testInventory()
671 changed.Windows = append(changed.Windows, ports.CastWindow{ID: 9, AppID: "x", Title: "new"})
672 changed.Outputs[0].X = 100
673 h.svc.Inventory(changed)
674 got := make(chan string, 8)
675 go func() {
676 for {
677 m, err := allowed.ReadMessage()
678 if err != nil {
679 return
680 }
681 if m.Type == zerobus.TypeSignal && (m.Member == "WindowsChanged" || m.Member == "MonitorsChanged") {
682 got <- m.Member
683 }
684 }
685 }()
686 seen := map[string]bool{}
687 for len(seen) < 2 {
688 select {
689 case m := <-got:
690 seen[m] = true
691 case <-time.After(5 * time.Second):
692 t.Fatalf("allowed peer heard %v", seen)
693 }
694 }
695 // The service's owner goroutine answers in order: a call that it
696 // answers (AccessDenied) comes after any signal it had wrongly directed
697 // to this connection. The handler is active during the call.
698 denied.Handle = func(m *zerobus.Message) {
699 if m.Member == "WindowsChanged" || m.Member == "MonitorsChanged" {
700 t.Errorf("refused peer got %s", m.Member)
701 }
702 }
703 if name, _ := callErr(denied, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
704 t.Fatalf("refused peer: %q", name)
705 }
706}
707
708// pidfdClosed reports whether the file was closed.
709func pidfdClosed(f *os.File) bool {
710 _, err := f.Stat()
711 return errors.Is(err, os.ErrClosed)
712}
713
714// Refused callers are not cached: more of them than the cache holds still
715// leave room for the allowed peer, and their pidfds are closed at once.
716func TestDeniedCallersDoNotExhaustTheCache(t *testing.T) {
717 const denied = maxCallers + 8
718 // One pidfd per check: the first peer is checked twice.
719 const deniedChecks = denied + 1
720 h := newSvcHarness(t, harnessOpts{})
721 h.peers.exeFile = func(n int) string {
722 if n < deniedChecks {
723 return "/usr/bin/evil"
724 }
725 return "/usr/bin/grim"
726 }
727 c0 := h.client()
728 h.waitName(c0, nameIntrospect)
729 for i := range denied {
730 x := h.client()
731 if name, _ := callErr(x, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
732 t.Fatalf("denied peer %d: %q", i, name)
733 }
734 // The same peer asking again is checked again, never LimitsExceeded.
735 if i == 0 {
736 if name, _ := callErr(x, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
737 t.Fatalf("denied peer again: %q", name)
738 }
739 }
740 }
741 good := h.client()
742 if name, _ := callErr(good, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" {
743 t.Fatalf("allowed peer after %d denied: %q", denied, name)
744 }
745 h.peers.mu.Lock()
746 opened := slices.Clone(h.peers.opened)
747 h.peers.mu.Unlock()
748 for i, f := range opened[:deniedChecks] {
749 if !pidfdClosed(f) {
750 t.Fatalf("pidfd of denied peer %d still open", i)
751 }
752 }
753 if pidfdClosed(opened[len(opened)-1]) {
754 t.Fatal("pidfd of the allowed peer closed while it is cached")
755 }
756}
757
758// The pidfds of the cached callers are closed when the bus is lost.
759func TestBusLossClosesCallerPidfds(t *testing.T) {
760 h := newSvcHarness(t, harnessOpts{})
761 c := h.client()
762 h.waitName(c, nameIntrospect)
763 if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" {
764 t.Fatal(name)
765 }
766 h.peers.mu.Lock()
767 opened := slices.Clone(h.peers.opened)
768 h.peers.mu.Unlock()
769 if len(opened) != 1 || pidfdClosed(opened[0]) {
770 t.Fatalf("%d pidfds, want one open", len(opened))
771 }
772 h.killBus()
773 deadline := time.Now().Add(5 * time.Second)
774 for !pidfdClosed(opened[0]) {
775 if time.Now().After(deadline) {
776 t.Fatal("pidfd of a cached caller survives the bus loss")
777 }
778 time.Sleep(5 * time.Millisecond)
779 }
780}