feat/data-dnd e05cb15

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	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}

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