detached 7a469c8

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
594594 t.Fatalf("windows %v", got)
595595 }
596596 }
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) {
2929 return
3030 }
3131 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")
3433 }
3534 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, "")
3753 s.send()
3854 }
3955 }
@@ -29,11 +29,27 @@ func (s *Service) applyInventory(inv ports.CastInventory) {
29 return29 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close