v0.4.0 b6be3a8

securitycontext_test.go RawHistoryFolder

1package wayland
2
3import (
4	"context"
5	"errors"
6	"os"
7	"path/filepath"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/bnema/go-wayland-bindings/server/securitycontext"
13	"github.com/bnema/neferwl/internal/adapters/logging"
14	"github.com/bnema/wlturbo"
15	"golang.org/x/sys/unix"
16)
17
18// sandboxListener is what a sandbox engine prepares: a listening socket and
19// the pipe whose hangup ends the context.
20type sandboxListener struct {
21	path     string
22	listen   int // engine's copy; the compositor gets a dup
23	closeR   int // goes to the compositor
24	closeW   int // the engine keeps it; closing it hangs up closeR
25	released bool
26}
27
28func newSandboxListener(t *testing.T) *sandboxListener {
29	t.Helper()
30	// Unix socket paths are short; t.TempDir can be long.
31	dir, err := os.MkdirTemp("", "sbx")
32	if err != nil {
33		t.Fatal(err)
34	}
35	t.Cleanup(func() { _ = os.RemoveAll(dir) })
36	l := &sandboxListener{path: filepath.Join(dir, "s")}
37	if l.listen, err = unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0); err != nil {
38		t.Fatal(err)
39	}
40	if err = unix.Bind(l.listen, &unix.SockaddrUnix{Name: l.path}); err != nil {
41		t.Fatal(err)
42	}
43	if err = unix.Listen(l.listen, 8); err != nil {
44		t.Fatal(err)
45	}
46	var p [2]int
47	if err = unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
48		t.Fatal(err)
49	}
50	l.closeR, l.closeW = p[0], p[1]
51	t.Cleanup(func() {
52		if !l.released {
53			_ = unix.Close(l.listen)
54		}
55		_ = unix.Close(l.closeR)
56		if l.closeW >= 0 {
57			_ = unix.Close(l.closeW)
58		}
59	})
60	return l
61}
62
63// handOver drops the engine's descriptors the compositor has its own copies
64// of (the protocol: closing them is the only valid operation afterwards). The
65// write end of the pipe stays with the engine.
66func (l *sandboxListener) handOver() {
67	_ = unix.Close(l.listen)
68	_ = unix.Close(l.closeR)
69	l.released, l.closeR = true, -1
70}
71
72func (l *sandboxListener) hangUp() {
73	_ = unix.Close(l.closeW)
74	l.closeW = -1
75}
76
77func (l *sandboxListener) connect(t *testing.T) *wlturbo.Display {
78	t.Helper()
79	c, err := wlturbo.Connect(l.path)
80	if err != nil {
81		t.Fatal(err)
82	}
83	t.Cleanup(func() { _ = c.Close(); forgetWireClient(c) })
84	if err := c.Roundtrip(); err != nil {
85		t.Fatal(err)
86	}
87	return c
88}
89
90// refused reports whether nothing listens on the socket any more.
91func (l *sandboxListener) refused() bool {
92	fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
93	if err != nil {
94		return false
95	}
96	defer unix.Close(fd)
97	return errors.Is(unix.Connect(fd, &unix.SockaddrUnix{Name: l.path}), unix.ECONNREFUSED)
98}
99
100func waitTrue(t *testing.T, what string, cond func() bool) {
101	t.Helper()
102	deadline := time.Now().Add(5 * time.Second)
103	for !cond() {
104		if time.Now().After(deadline) {
105			t.Fatal("timeout: " + what)
106		}
107		time.Sleep(2 * time.Millisecond)
108	}
109}
110
111// contextClient is a client of the main socket with a security context being built.
112type contextClient struct {
113	c       *wlturbo.Display
114	manager uint32
115	ctx     uint32
116}
117
118func newContextClient(t *testing.T, h *admissionHarness) *contextClient {
119	t.Helper()
120	c := protocolClient(t, h.s, h.dir)
121	return &contextClient{c: c, manager: bindProtocol(t, c, "wp_security_context_manager_v1")}
122}
123
124// create sends create_listener with the engine's descriptors.
125func (cc *contextClient) create(t *testing.T, listen, closeFD int) {
126	t.Helper()
127	cc.ctx = cc.c.AllocateID()
128	if err := wireRequest(cc.c, cc.manager, uint16(securitycontext.WpSecurityContextManagerV1RequestCreateListener), []int{listen, closeFD}, cc.ctx); err != nil {
129		t.Fatal(err)
130	}
131}
132
133func (cc *contextClient) request(t *testing.T, op uint32, args ...any) {
134	t.Helper()
135	requestProtocol(t, cc.c, cc.ctx, op, args...)
136}
137
138// commit makes a context with an engine, like a Flatpak would, and waits for
139// the compositor to have processed it.
140func (cc *contextClient) commit(t *testing.T, l *sandboxListener, engine, app string) {
141	t.Helper()
142	cc.create(t, l.listen, l.closeR)
143	cc.request(t, securitycontext.WpSecurityContextV1RequestSetSandboxEngine, engine)
144	if app != "" {
145		cc.request(t, securitycontext.WpSecurityContextV1RequestSetAppId, app)
146	}
147	cc.request(t, securitycontext.WpSecurityContextV1RequestCommit)
148	if err := cc.c.Roundtrip(); err != nil {
149		t.Fatal(err)
150	}
151	l.handOver()
152}
153
154func (h *admissionHarness) sandboxedCount() int {
155	n := -1
156	h.s.display.Do(func() { n = len(h.s.sandboxed) })
157	return n
158}
159
160// A client that connects through a committed context is sandboxed: it cannot
161// capture, while a client of the main socket still can (the allowlist is `*` here).
162func TestSecurityContextSandboxedClientCannotCapture(t *testing.T) {
163	h := newAdmissionHarnessWith(t, maxCaptureInflight+2, Options{CaptureAllow: allowStore(t, "*\n")}, logging.For(context.Background(), "wayland"))
164	plain := h.client(t)
165	if _, ok := h.accepted(t, plain.captureWlr(t)); !ok {
166		t.Fatal("plain client refused")
167	}
168
169	l := newSandboxListener(t)
170	cc := newContextClient(t, h)
171	cc.commit(t, l, "org.flatpak", "org.example.App")
172
173	sandboxed := h.clientOn(t, l.connect(t))
174	waitTrue(t, "client tagged", func() bool { return h.sandboxedCount() == 1 })
175	for i := 0; i < 2; i++ {
176		if _, ok := h.accepted(t, sandboxed.captureWlr(t)); ok {
177			t.Fatal("sandboxed wlr capture allowed")
178		}
179	}
180	if !sandboxed.extSessionStopped(t) {
181		t.Fatal("sandboxed ext capture allowed")
182	}
183	// The committing client is not sandboxed by it, and is unaffected.
184	if _, ok := h.accepted(t, plain.captureWlr(t)); !ok {
185		t.Fatal("plain client refused after the context")
186	}
187	// The tag goes with the client.
188	_ = sandboxed.c.Close()
189	waitTrue(t, "tag dropped", func() bool { return h.sandboxedCount() == 0 })
190}
191
192// Sandboxed clients refuse even when the allowlist is off or lists them.
193func TestSecurityContextOverridesAllowlist(t *testing.T) {
194	self, _ := os.Executable()
195	for _, content := range []string{"*\n", self + "\n"} {
196		h, out := authHarness(t, allowStore(t, content), nil)
197		l := newSandboxListener(t)
198		newContextClient(t, h).commit(t, l, "org.flatpak", "")
199		c := h.clientOn(t, l.connect(t))
200		if _, ok := h.accepted(t, c.captureWlr(t)); ok {
201			t.Fatalf("sandboxed client captured with allowlist %q", content)
202		}
203		if !strings.Contains(out.String(), `"reason":"sandboxed client"`) {
204			t.Fatalf("log = %s", out.String())
205		}
206	}
207}
208
209// The listener keeps serving after the client that created it is gone, takes
210// several connections, and stops when close_fd hangs up.
211func TestSecurityContextListenerLifecycle(t *testing.T) {
212	h := newAdmissionHarness(t, maxCaptureInflight+2)
213	l := newSandboxListener(t)
214	cc := newContextClient(t, h)
215	cc.commit(t, l, "org.flatpak", "org.example.App")
216	_ = cc.c.Close() // the protocol: the listener outlives the client that made it
217	waitTrue(t, "creator gone", func() bool {
218		n := 0
219		h.s.display.Do(func() { n = len(h.s.peers) })
220		return n == 0
221	})
222	a, b := h.clientOn(t, l.connect(t)), h.clientOn(t, l.connect(t))
223	waitTrue(t, "two tagged", func() bool { return h.sandboxedCount() == 2 })
224	for _, c := range []*admissionClient{a, b} {
225		if _, ok := h.accepted(t, c.captureWlr(t)); ok {
226			t.Fatal("sandboxed client captured")
227		}
228	}
229	if h.s.securityLive.Load() != 1 {
230		t.Fatalf("listeners = %d", h.s.securityLive.Load())
231	}
232	l.hangUp()
233	waitTrue(t, "listener stopped", func() bool { return h.s.securityLive.Load() == 0 && l.refused() })
234	// Clients already connected are untouched.
235	if err := a.c.Roundtrip(); err != nil {
236		t.Fatal(err)
237	}
238}
239
240// Compositor shutdown ends the listeners and closes their sockets.
241func TestSecurityContextStopsWithTheCompositor(t *testing.T) {
242	dir := t.TempDir()
243	s, err := New(Options{RuntimeDir: dir}, Channels{}, logging.For(context.Background(), "wayland"))
244	if err != nil {
245		t.Fatal(err)
246	}
247	ctx, cancel := context.WithCancel(context.Background())
248	done := make(chan error, 1)
249	go func() { done <- s.Run(ctx) }()
250	defer cancel()
251	c := protocolClient(t, s, dir)
252	manager := bindProtocol(t, c, "wp_security_context_manager_v1")
253	l := newSandboxListener(t)
254	id := c.AllocateID()
255	if err := wireRequest(c, manager, uint16(securitycontext.WpSecurityContextManagerV1RequestCreateListener), []int{l.listen, l.closeR}, id); err != nil {
256		t.Fatal(err)
257	}
258	requestProtocol(t, c, id, securitycontext.WpSecurityContextV1RequestSetSandboxEngine, "org.flatpak")
259	requestProtocol(t, c, id, securitycontext.WpSecurityContextV1RequestCommit)
260	if err := c.Roundtrip(); err != nil {
261		t.Fatal(err)
262	}
263	l.handOver()
264	waitTrue(t, "listening", func() bool { return s.securityLive.Load() == 1 })
265	cancel()
266	select {
267	case err := <-done:
268		if err != nil {
269			t.Fatal(err)
270		}
271	case <-time.After(5 * time.Second):
272		t.Fatal("Run did not return")
273	}
274	if s.securityLive.Load() != 0 || !l.refused() {
275		t.Fatalf("listener left behind: live=%d refused=%v", s.securityLive.Load(), l.refused())
276	}
277}
278
279// Destroying a context that was never committed closes the descriptors: the
280// socket stops listening.
281func TestSecurityContextDestroyBeforeCommit(t *testing.T) {
282	h := newAdmissionHarness(t, 2)
283	l := newSandboxListener(t)
284	cc := newContextClient(t, h)
285	cc.create(t, l.listen, l.closeR)
286	if err := cc.c.Roundtrip(); err != nil {
287		t.Fatal(err)
288	}
289	l.handOver()
290	if l.refused() {
291		t.Fatal("socket stopped listening before the context was destroyed")
292	}
293	cc.request(t, securitycontext.WpSecurityContextV1RequestDestroy)
294	if err := cc.c.Roundtrip(); err != nil {
295		t.Fatal(err)
296	}
297	waitTrue(t, "descriptors closed", l.refused)
298	if h.s.securityLive.Load() != 0 {
299		t.Fatal("an uncommitted context listens")
300	}
301}
302
303func TestSecurityContextErrors(t *testing.T) {
304	const (
305		ctxAlreadyUsed = uint32(securitycontext.WpSecurityContextV1ErrorAlreadyUsed)
306		ctxAlreadySet  = uint32(securitycontext.WpSecurityContextV1ErrorAlreadySet)
307		ctxInvalidMeta = uint32(securitycontext.WpSecurityContextV1ErrorInvalidMetadata)
308	)
309	engine := uint32(securitycontext.WpSecurityContextV1RequestSetSandboxEngine)
310	app := uint32(securitycontext.WpSecurityContextV1RequestSetAppId)
311	inst := uint32(securitycontext.WpSecurityContextV1RequestSetInstanceId)
312	commit := uint32(securitycontext.WpSecurityContextV1RequestCommit)
313	type step struct {
314		op  uint32
315		arg []any
316	}
317	tests := []struct {
318		name  string
319		steps []step
320		code  uint32
321	}{
322		{"engine twice", []step{{engine, []any{"a"}}, {engine, []any{"b"}}}, ctxAlreadySet},
323		{"app id twice", []step{{app, []any{"a"}}, {app, []any{"b"}}}, ctxAlreadySet},
324		{"instance id twice", []step{{inst, []any{"a"}}, {inst, []any{"b"}}}, ctxAlreadySet},
325		{"empty engine", []step{{engine, []any{""}}}, ctxInvalidMeta},
326		{"control characters", []step{{app, []any{"a\x01b"}}}, ctxInvalidMeta},
327		{"oversized", []step{{app, []any{strings.Repeat("x", maxSecurityMetadata+1)}}}, ctxInvalidMeta},
328		{"commit without engine", []step{{commit, nil}}, ctxInvalidMeta},
329		{"set after commit", []step{{engine, []any{"a"}}, {commit, nil}, {app, []any{"a"}}}, ctxAlreadyUsed},
330		{"commit twice", []step{{engine, []any{"a"}}, {commit, nil}, {commit, nil}}, ctxAlreadyUsed},
331	}
332	for _, tc := range tests {
333		t.Run(tc.name, func(t *testing.T) {
334			h := newAdmissionHarness(t, 2)
335			l := newSandboxListener(t)
336			cc := newContextClient(t, h)
337			cc.create(t, l.listen, l.closeR)
338			for _, s := range tc.steps {
339				cc.request(t, s.op, s.arg...)
340			}
341			expectProtocolError(t, cc.c, cc.ctx, tc.code)
342			l.handOver()
343		})
344	}
345}
346
347func TestSecurityContextInvalidListenFD(t *testing.T) {
348	h := newAdmissionHarness(t, 2)
349	// A connected socket, a regular pipe and a datagram socket listen on nothing.
350	pair, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
351	if err != nil {
352		t.Fatal(err)
353	}
354	defer unix.Close(pair[1])
355	dgram, err := unix.Socket(unix.AF_UNIX, unix.SOCK_DGRAM|unix.SOCK_CLOEXEC, 0)
356	if err != nil {
357		t.Fatal(err)
358	}
359	defer unix.Close(dgram)
360	pipe := make([]int, 2)
361	if err := unix.Pipe2(pipe, unix.O_CLOEXEC); err != nil {
362		t.Fatal(err)
363	}
364	defer unix.Close(pipe[1])
365	for name, fd := range map[string]int{"connected": pair[0], "datagram": dgram, "pipe": pipe[0]} {
366		t.Run(name, func(t *testing.T) {
367			cc := newContextClient(t, h)
368			r, w := mustPipe(t)
369			defer unix.Close(w)
370			cc.create(t, fd, r)
371			expectProtocolError(t, cc.c, cc.manager, uint32(securitycontext.WpSecurityContextManagerV1ErrorInvalidListenFd))
372			_ = unix.Close(r)
373		})
374	}
375}
376
377func mustPipe(t *testing.T) (r, w int) {
378	t.Helper()
379	var p [2]int
380	if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
381		t.Fatal(err)
382	}
383	return p[0], p[1]
384}
385
386// A sandboxed client may bind the manager (binding every global is common)
387// but create_listener is the nested error.
388func TestSecurityContextNestedForbidden(t *testing.T) {
389	h := newAdmissionHarness(t, 2)
390	l := newSandboxListener(t)
391	newContextClient(t, h).commit(t, l, "org.flatpak", "")
392	c := l.connect(t)
393	manager := bindProtocol(t, c, "wp_security_context_manager_v1")
394	waitTrue(t, "tagged", func() bool { return h.sandboxedCount() == 1 })
395	inner := newSandboxListener(t)
396	id := c.AllocateID()
397	if err := wireRequest(c, manager, uint16(securitycontext.WpSecurityContextManagerV1RequestCreateListener), []int{inner.listen, inner.closeR}, id); err != nil {
398		t.Fatal(err)
399	}
400	expectProtocolError(t, c, manager, uint32(securitycontext.WpSecurityContextManagerV1ErrorNested))
401	// The compositor closed its copies: nothing listens on the inner socket
402	// once ours goes too.
403	inner.handOver()
404	waitTrue(t, "inner closed", inner.refused)
405}

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