main f2d166b

bus_test.go RawHistoryFolder

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}

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 25

Tags 10

↑↓ moveenter openesc close