fix(screencast): send MonitorsChanged and WindowsChanged only to allowed peers
- commit
- 7a469c8a57dc1ef5a216e66bb88a19835fd5d37b
- author
- brice · · 2026-10-14 21:30 UTC
- committer
- brice · · 2026-10-14 21:39 UTC
- parent
- 4a05b71
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +109 -3
M internal/adapters/screencast/bus_test.go +90 -0
| @@ -594,3 +594,93 @@ func TestDisplayConfigAndWindows(t *testing.T) { | |||
| 594 | 594 | t.Fatalf("windows %v", got) | |
| 595 | 595 | } | |
| 596 | 596 | } | |
| 597 | + | ||
| 598 | + | // MonitorsChanged and WindowsChanged are directed: a peer that was refused | |
| 599 | + | // gets nothing, even with a match rule for them; an allowed one gets them, | |
| 600 | + | // and a peer that left the bus is forgotten. | |
| 601 | + | func TestInventorySignalsGoToAllowedPeersOnly(t *testing.T) { | |
| 602 | + | h := newSvcHarness(t, harnessOpts{}) | |
| 603 | + | h.peers.exeFile = func(n int) string { | |
| 604 | + | if n == 0 || n == 2 { | |
| 605 | + | return "/usr/bin/grim" | |
| 606 | + | } | |
| 607 | + | return "/usr/bin/evil" | |
| 608 | + | } | |
| 609 | + | allowed, denied, gone := h.client(), h.client(), h.client() | |
| 610 | + | for _, n := range []string{nameDisplayConfig, nameIntrospect} { | |
| 611 | + | h.waitName(allowed, n) | |
| 612 | + | } | |
| 613 | + | rule := "type='signal',path='" + pathIntrospect + "'" | |
| 614 | + | rule2 := "type='signal',path='" + pathDisplayConfig + "'" | |
| 615 | + | for _, x := range []*zerobus.Conn{allowed, denied, gone} { | |
| 616 | + | for _, r := range []string{rule, rule2} { | |
| 617 | + | if err := x.AddMatch(r); err != nil { | |
| 618 | + | t.Fatal(err) | |
| 619 | + | } | |
| 620 | + | } | |
| 621 | + | } | |
| 622 | + | // Check in order: allowed (pidfd 0), denied (1), gone (2). | |
| 623 | + | for i, x := range []*zerobus.Conn{allowed, denied, gone} { | |
| 624 | + | want := "" | |
| 625 | + | if i == 1 { | |
| 626 | + | want = errAccessDenied | |
| 627 | + | } | |
| 628 | + | if name, _ := callErr(x, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != want { | |
| 629 | + | t.Fatalf("peer %d: %q, want %q", i, name, want) | |
| 630 | + | } | |
| 631 | + | } | |
| 632 | + | _ = gone.Close() | |
| 633 | + | // The first inventory is not a change. Wait until the owner applied it | |
| 634 | + | // (a published inventory replaces an unread one), then publish a change. | |
| 635 | + | h.svc.Inventory(testInventory()) | |
| 636 | + | deadline := time.Now().Add(5 * time.Second) | |
| 637 | + | for { | |
| 638 | + | _, m := callErr(allowed, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil) | |
| 639 | + | if m != nil && m.Signature == "a{ta{sv}}" { | |
| 640 | + | r := m.Body() | |
| 641 | + | if end := r.Array('{'); r.More(end) { | |
| 642 | + | break | |
| 643 | + | } | |
| 644 | + | } | |
| 645 | + | if time.Now().After(deadline) { | |
| 646 | + | t.Fatal("inventory not applied") | |
| 647 | + | } | |
| 648 | + | time.Sleep(5 * time.Millisecond) | |
| 649 | + | } | |
| 650 | + | changed := testInventory() | |
| 651 | + | changed.Windows = append(changed.Windows, ports.CastWindow{ID: 9, AppID: "x", Title: "new"}) | |
| 652 | + | changed.Outputs[0].X = 100 | |
| 653 | + | h.svc.Inventory(changed) | |
| 654 | + | got := make(chan string, 8) | |
| 655 | + | go func() { | |
| 656 | + | for { | |
| 657 | + | m, err := allowed.ReadMessage() | |
| 658 | + | if err != nil { | |
| 659 | + | return | |
| 660 | + | } | |
| 661 | + | if m.Type == zerobus.TypeSignal && (m.Member == "WindowsChanged" || m.Member == "MonitorsChanged") { | |
| 662 | + | got <- m.Member | |
| 663 | + | } | |
| 664 | + | } | |
| 665 | + | }() | |
| 666 | + | seen := map[string]bool{} | |
| 667 | + | for len(seen) < 2 { | |
| 668 | + | select { | |
| 669 | + | case m := <-got: | |
| 670 | + | seen[m] = true | |
| 671 | + | case <-time.After(5 * time.Second): | |
| 672 | + | t.Fatalf("allowed peer heard %v", seen) | |
| 673 | + | } | |
| 674 | + | } | |
| 675 | + | // A round trip through the service orders the signals before its answer: | |
| 676 | + | // the denied peer would have heard them by now. | |
| 677 | + | denied.Handle = func(m *zerobus.Message) { | |
| 678 | + | if m.Member == "WindowsChanged" || m.Member == "MonitorsChanged" { | |
| 679 | + | t.Errorf("refused peer got %s", m.Member) | |
| 680 | + | } | |
| 681 | + | } | |
| 682 | + | denied.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetId", "") | |
| 683 | + | if _, err := denied.Call(); err != nil { | |
| 684 | + | t.Fatal(err) | |
| 685 | + | } | |
| 686 | + | } | |
| @@ -594,3 +594,93 @@ func TestDisplayConfigAndWindows(t *testing.T) { | |||||
| 594 | t.Fatalf("windows %v", got) | 594 | t.Fatalf("windows %v", got) | ||
| 595 | } | 595 | } | ||
| 596 | } | 596 | } | ||
| 597 | + | ||||
| 598 | + | // MonitorsChanged and WindowsChanged are directed: a peer that was refused | |||
| 599 | + | // gets nothing, even with a match rule for them; an allowed one gets them, | |||
| 600 | + | // and a peer that left the bus is forgotten. | |||
| 601 | + | func TestInventorySignalsGoToAllowedPeersOnly(t *testing.T) { | |||
| 602 | + | h := newSvcHarness(t, harnessOpts{}) | |||
| 603 | + | h.peers.exeFile = func(n int) string { | |||
| 604 | + | if n == 0 || n == 2 { | |||
| 605 | + | return "/usr/bin/grim" | |||
| 606 | + | } | |||
| 607 | + | return "/usr/bin/evil" | |||
| 608 | + | } | |||
| 609 | + | allowed, denied, gone := h.client(), h.client(), h.client() | |||
| 610 | + | for _, n := range []string{nameDisplayConfig, nameIntrospect} { | |||
| 611 | + | h.waitName(allowed, n) | |||
| 612 | + | } | |||
| 613 | + | rule := "type='signal',path='" + pathIntrospect + "'" | |||
| 614 | + | rule2 := "type='signal',path='" + pathDisplayConfig + "'" | |||
| 615 | + | for _, x := range []*zerobus.Conn{allowed, denied, gone} { | |||
| 616 | + | for _, r := range []string{rule, rule2} { | |||
| 617 | + | if err := x.AddMatch(r); err != nil { | |||
| 618 | + | t.Fatal(err) | |||
| 619 | + | } | |||
| 620 | + | } | |||
| 621 | + | } | |||
| 622 | + | // Check in order: allowed (pidfd 0), denied (1), gone (2). | |||
| 623 | + | for i, x := range []*zerobus.Conn{allowed, denied, gone} { | |||
| 624 | + | want := "" | |||
| 625 | + | if i == 1 { | |||
| 626 | + | want = errAccessDenied | |||
| 627 | + | } | |||
| 628 | + | if name, _ := callErr(x, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != want { | |||
| 629 | + | t.Fatalf("peer %d: %q, want %q", i, name, want) | |||
| 630 | + | } | |||
| 631 | + | } | |||
| 632 | + | _ = gone.Close() | |||
| 633 | + | // The first inventory is not a change. Wait until the owner applied it | |||
| 634 | + | // (a published inventory replaces an unread one), then publish a change. | |||
| 635 | + | h.svc.Inventory(testInventory()) | |||
| 636 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 637 | + | for { | |||
| 638 | + | _, m := callErr(allowed, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil) | |||
| 639 | + | if m != nil && m.Signature == "a{ta{sv}}" { | |||
| 640 | + | r := m.Body() | |||
| 641 | + | if end := r.Array('{'); r.More(end) { | |||
| 642 | + | break | |||
| 643 | + | } | |||
| 644 | + | } | |||
| 645 | + | if time.Now().After(deadline) { | |||
| 646 | + | t.Fatal("inventory not applied") | |||
| 647 | + | } | |||
| 648 | + | time.Sleep(5 * time.Millisecond) | |||
| 649 | + | } | |||
| 650 | + | changed := testInventory() | |||
| 651 | + | changed.Windows = append(changed.Windows, ports.CastWindow{ID: 9, AppID: "x", Title: "new"}) | |||
| 652 | + | changed.Outputs[0].X = 100 | |||
| 653 | + | h.svc.Inventory(changed) | |||
| 654 | + | got := make(chan string, 8) | |||
| 655 | + | go func() { | |||
| 656 | + | for { | |||
| 657 | + | m, err := allowed.ReadMessage() | |||
| 658 | + | if err != nil { | |||
| 659 | + | return | |||
| 660 | + | } | |||
| 661 | + | if m.Type == zerobus.TypeSignal && (m.Member == "WindowsChanged" || m.Member == "MonitorsChanged") { | |||
| 662 | + | got <- m.Member | |||
| 663 | + | } | |||
| 664 | + | } | |||
| 665 | + | }() | |||
| 666 | + | seen := map[string]bool{} | |||
| 667 | + | for len(seen) < 2 { | |||
| 668 | + | select { | |||
| 669 | + | case m := <-got: | |||
| 670 | + | seen[m] = true | |||
| 671 | + | case <-time.After(5 * time.Second): | |||
| 672 | + | t.Fatalf("allowed peer heard %v", seen) | |||
| 673 | + | } | |||
| 674 | + | } | |||
| 675 | + | // A round trip through the service orders the signals before its answer: | |||
| 676 | + | // the denied peer would have heard them by now. | |||
| 677 | + | denied.Handle = func(m *zerobus.Message) { | |||
| 678 | + | if m.Member == "WindowsChanged" || m.Member == "MonitorsChanged" { | |||
| 679 | + | t.Errorf("refused peer got %s", m.Member) | |||
| 680 | + | } | |||
| 681 | + | } | |||
| 682 | + | denied.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetId", "") | |||
| 683 | + | if _, err := denied.Call(); err != nil { | |||
| 684 | + | t.Fatal(err) | |||
| 685 | + | } | |||
| 686 | + | } | |||
M internal/adapters/screencast/inventory.go +19 -3
| @@ -29,11 +29,27 @@ func (s *Service) applyInventory(inv ports.CastInventory) { | |||
| 29 | 29 | return | |
| 30 | 30 | } | |
| 31 | 31 | if s.owns[1] && !reflect.DeepEqual(old.Outputs, inv.Outputs) { | |
| 32 | - | s.conn.NewSignal(pathDisplayConfig, ifaceDisplayConfig, "MonitorsChanged", "") | |
| 33 | - | s.send() | |
| 32 | + | s.notifyAllowed(pathDisplayConfig, ifaceDisplayConfig, "MonitorsChanged") | |
| 34 | 33 | } | |
| 35 | 34 | if s.owns[2] && !reflect.DeepEqual(old.Windows, inv.Windows) { | |
| 36 | - | s.conn.NewSignal(pathIntrospect, ifaceIntrospect, "WindowsChanged", "") | |
| 35 | + | s.notifyAllowed(pathIntrospect, ifaceIntrospect, "WindowsChanged") | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // notifyAllowed sends a body-less signal to each peer that passed the caller | |
| 40 | + | // check and whose executable the allowlist admits now. The signals are | |
| 41 | + | // directed: the inventory (output names, window titles) is never broadcast, | |
| 42 | + | // so a peer that was refused, or has not been checked, learns nothing. At | |
| 43 | + | // most maxCallers peers are served, from the cache, without allocation. The | |
| 44 | + | // executable is read again through the pidfd, as at the privileged entry | |
| 45 | + | // points. Peers that left the bus are gone from the cache (onGone). | |
| 46 | + | func (s *Service) notifyAllowed(path, iface, member string) { | |
| 47 | + | for i := range s.callers { | |
| 48 | + | c := &s.callers[i] | |
| 49 | + | if c.state != callerKnown || !s.allowed(c.exe) || !s.recheck(c) { | |
| 50 | + | continue | |
| 51 | + | } | |
| 52 | + | s.conn.NewSignalTo(c.name, path, iface, member, "") | |
| 37 | 53 | s.send() | |
| 38 | 54 | } | |
| 39 | 55 | } | |
| @@ -29,11 +29,27 @@ func (s *Service) applyInventory(inv ports.CastInventory) { | |||||
| 29 | return | 29 | return | ||
| 30 | } | 30 | } | ||
| 31 | if s.owns[1] && !reflect.DeepEqual(old.Outputs, inv.Outputs) { | 31 | if s.owns[1] && !reflect.DeepEqual(old.Outputs, inv.Outputs) { | ||
| 32 | - | s.conn.NewSignal(pathDisplayConfig, ifaceDisplayConfig, "MonitorsChanged", "") | 32 | + | s.notifyAllowed(pathDisplayConfig, ifaceDisplayConfig, "MonitorsChanged") |
| 33 | - | s.send() | |||
| 34 | } | 33 | } | ||
| 35 | if s.owns[2] && !reflect.DeepEqual(old.Windows, inv.Windows) { | 34 | if s.owns[2] && !reflect.DeepEqual(old.Windows, inv.Windows) { | ||
| 36 | - | s.conn.NewSignal(pathIntrospect, ifaceIntrospect, "WindowsChanged", "") | 35 | + | s.notifyAllowed(pathIntrospect, ifaceIntrospect, "WindowsChanged") |
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // notifyAllowed sends a body-less signal to each peer that passed the caller | |||
| 40 | + | // check and whose executable the allowlist admits now. The signals are | |||
| 41 | + | // directed: the inventory (output names, window titles) is never broadcast, | |||
| 42 | + | // so a peer that was refused, or has not been checked, learns nothing. At | |||
| 43 | + | // most maxCallers peers are served, from the cache, without allocation. The | |||
| 44 | + | // executable is read again through the pidfd, as at the privileged entry | |||
| 45 | + | // points. Peers that left the bus are gone from the cache (onGone). | |||
| 46 | + | func (s *Service) notifyAllowed(path, iface, member string) { | |||
| 47 | + | for i := range s.callers { | |||
| 48 | + | c := &s.callers[i] | |||
| 49 | + | if c.state != callerKnown || !s.allowed(c.exe) || !s.recheck(c) { | |||
| 50 | + | continue | |||
| 51 | + | } | |||
| 52 | + | s.conn.NewSignalTo(c.name, path, iface, member, "") | |||
| 37 | s.send() | 53 | s.send() | ||
| 38 | } | 54 | } | ||
| 39 | } | 55 | } | ||