1package wayland
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "net"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 core "github.com/bnema/go-wayland-bindings/client/wayland"
13 "github.com/bnema/wlturbo"
14 "github.com/stretchr/testify/require"
15
16 "github.com/bnema/nefercap/internal/adapters/wayland/testserver"
17 "github.com/bnema/nefercap/internal/logging"
18 "github.com/bnema/nefercap/internal/ports"
19)
20
21func extConfig() testserver.Config {
22 return testserver.Config{
23 Outputs: []testserver.OutputSpec{{Name: "BIG", Width: 64, Height: 48, Scale: 1}},
24 NeferwlSource: true, NeferwlExclusion: true,
25 }
26}
27
28func TestCapabilitiesWithExtension(t *testing.T) {
29 s, _ := newSource(t, extConfig())
30 require.Equal(t, ports.Capabilities{Exclusion: true}, s.Capabilities())
31}
32
33func TestRegionUsesExtensionSource(t *testing.T) {
34 s, srv := newSource(t, extConfig())
35 out := firstOutput(t, s)
36 f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 10, Y: 5, Width: 20, Height: 8}})
37 require.NoError(t, err)
38 require.NoError(t, f.Validate())
39 require.Equal(t, [3]int{20, 8, 80}, [3]int{f.Width, f.Height, f.Stride}, "the compositor sends only the region: no crop")
40 st := srv.Stats()
41 require.Equal(t, "region", st.LastSource)
42 require.Equal(t, [4]int32{10, 5, 20, 8}, st.LastRegion)
43 want := testserver.Pixel(10, 5, 1)
44 require.Equal(t, want[:], f.Row(0)[:4])
45 // the same region reuses the session; another one replaces it
46 _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 10, Y: 5, Width: 20, Height: 8}})
47 require.NoError(t, err)
48 require.Equal(t, 1, srv.Stats().Sessions)
49 _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 0, Y: 0, Width: 4, Height: 4}})
50 require.NoError(t, err)
51 st = srv.Stats()
52 require.Equal(t, 2, st.Sessions)
53 require.Equal(t, 1, st.DestroyedSessions)
54}
55
56func TestBeginExclusionNeedsExtension(t *testing.T) {
57 s, _ := newSource(t, testserver.Config{})
58 _, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID})
59 require.ErrorIs(t, err, ErrExclusionUnsupported)
60}
61
62func TestBeginExclusionAndCaptureKeepSession(t *testing.T) {
63 s, srv := newSource(t, extConfig())
64 target := ports.Target{OutputID: firstOutput(t, s).ID}
65 token, err := s.BeginExclusion(context.Background(), target)
66 require.NoError(t, err)
67 require.Equal(t, testserver.Token, token)
68 for range 3 {
69 _, err = s.Capture(context.Background(), target)
70 require.NoError(t, err)
71 }
72 st := srv.Stats()
73 require.Equal(t, 1, st.Sessions, "capture reuses the session that owns the exclusion")
74 require.Equal(t, 1, st.Exclusions)
75 _, err = s.BeginExclusion(context.Background(), target)
76 require.Error(t, err, "one exclusion per session")
77}
78
79func TestExclusionRefused(t *testing.T) {
80 cfg := extConfig()
81 cfg.ExclusionFail = 1 // busy
82 s, _ := newSource(t, cfg)
83 _, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID})
84 require.ErrorIs(t, err, ErrExclusionUnavailable)
85}
86
87// Any refusal that is neither busy nor session_stopped is a plain
88// ErrExclusionUnavailable carrying the reason.
89func TestExclusionOtherReasonIsUnavailable(t *testing.T) {
90 cfg := extConfig()
91 cfg.ExclusionFail = 4 // reason 3
92 s, _ := newSource(t, cfg)
93 _, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID})
94 require.ErrorIs(t, err, ErrExclusionUnavailable)
95 require.NotErrorIs(t, err, ErrExclusionBusy)
96 require.NotErrorIs(t, err, ports.ErrCaptureStopped)
97 require.ErrorContains(t, err, "reason 3")
98}
99
100func TestExclusionSessionStoppedStaysStopped(t *testing.T) {
101 s, srv := newSource(t, extConfig())
102 target := ports.Target{OutputID: firstOutput(t, s).ID}
103 _, err := s.BeginExclusion(context.Background(), target)
104 require.NoError(t, err)
105 srv.StopSessions()
106 for range 2 {
107 _, err = s.Capture(context.Background(), target)
108 require.ErrorIs(t, err, ports.ErrCaptureStopped, "a recording never continues on a session without its exclusion")
109 }
110 require.Equal(t, 1, srv.Stats().Sessions)
111}
112
113func TestExclusionEndsWithTargetChange(t *testing.T) {
114 s, srv := newSource(t, extConfig())
115 out := firstOutput(t, s)
116 _, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: out.ID})
117 require.NoError(t, err)
118 _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 4, Height: 4}})
119 require.NoError(t, err)
120 require.Equal(t, 1, srv.Stats().DestroyedExclusions)
121}
122
123// hudConnection is a second connection, like the HUD's, with a surface and a
124// goroutine that reads its events, as neferclient's reader does.
125func hudConnection(t *testing.T, srv *testserver.Server) (*wlturbo.Display, *core.Surface) {
126 t.Helper()
127 conn, err := net.Dial("unix", srv.Path)
128 require.NoError(t, err)
129 display, err := wlturbo.ConnectFromConn(conn)
130 require.NoError(t, err)
131 t.Cleanup(func() { _ = display.Close() })
132 require.NoError(t, display.Roundtrip())
133 comp := core.NewCompositor(display.Context())
134 _, err = display.Registry().BindNegotiated("wl_compositor", 4, comp)
135 require.NoError(t, err)
136 surface, err := comp.CreateSurface()
137 require.NoError(t, err)
138 read := make(chan struct{})
139 go func() {
140 defer close(read)
141 for display.Dispatch() == nil {
142 }
143 }()
144 t.Cleanup(func() { _ = display.Close(); <-read })
145 return display, surface
146}
147
148func TestAuthorizeLayerWaitsForAttached(t *testing.T) {
149 _, srv := newSource(t, extConfig())
150 display, surface := hudConnection(t, srv)
151 require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil))
152 require.Equal(t, []string{testserver.Token}, srv.Stats().Attached)
153}
154
155func TestAuthorizeLayerAttachFailed(t *testing.T) {
156 for reason, want := range map[uint32]AttachFailure{
157 1: AttachUnknownToken, 2: AttachUnauthorized, 3: AttachTooManyLayers,
158 4: AttachAlreadyAttached, 5: AttachExclusionEnded, 6: AttachLayerDestroyed,
159 } {
160 cfg := extConfig()
161 cfg.AttachFail = reason
162 srv := testserver.Start(t, cfg)
163 display, surface := hudConnection(t, srv)
164 err := AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil)
165 require.ErrorIs(t, err, ErrAttachFailed)
166 var failed *AttachFailedError
167 require.ErrorAs(t, err, &failed)
168 require.Equal(t, want, failed.Reason)
169 require.Equal(t, want == AttachUnknownToken || want == AttachExclusionEnded, errors.Is(err, ErrExclusionUnavailable))
170 require.Equal(t, want == AttachUnauthorized, errors.Is(err, ErrUnauthorized))
171 require.NotContains(t, err.Error(), testserver.Token)
172 }
173}
174
175func TestAuthorizeLayerDetachedCallback(t *testing.T) {
176 _, srv := newSource(t, extConfig())
177 display, surface := hudConnection(t, srv)
178 var got atomic.Pointer[LayerDetachedError]
179 calls := make(chan struct{}, 4)
180 require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) {
181 var d *LayerDetachedError
182 if errors.As(err, &d) {
183 got.Store(d)
184 }
185 calls <- struct{}{}
186 }))
187 require.Nil(t, got.Load())
188 srv.Detach(0, uint32(DetachExclusionEnded))
189 select {
190 case <-calls:
191 case <-time.After(5 * time.Second):
192 t.Fatal("onDetached was not called")
193 }
194 d := got.Load()
195 require.NotNil(t, d)
196 require.ErrorIs(t, d, ErrExclusionUnavailable)
197 require.NotContains(t, d.Error(), testserver.Token)
198}
199
200func TestAuthorizeLayerProtocolViolationClosesDisplay(t *testing.T) {
201 _, srv := newSource(t, extConfig())
202 display, surface := hudConnection(t, srv)
203 got := make(chan error, 1)
204 require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) { got <- err }))
205 srv.SendRaw(0, 0, 0) // a second attached
206 select {
207 case err := <-got:
208 require.ErrorIs(t, err, ErrExclusionUnavailable)
209 case <-time.After(5 * time.Second):
210 t.Fatal("onDetached was not called")
211 }
212 require.True(t, display.Closed(), "the surface could be visible in captures: the connection ends")
213}
214
215func TestAuthorizeLayerContextAndTimeout(t *testing.T) {
216 cfg := extConfig()
217 cfg.AttachSilent = true
218 srv := testserver.Start(t, cfg)
219 display, surface := hudConnection(t, srv)
220 ctx, cancel := context.WithCancel(context.Background())
221 done := make(chan error, 1)
222 go func() { done <- AuthorizeLayer(ctx, display, surface, testserver.Token, nil) }()
223 require.Eventually(t, func() bool { return len(srv.Stats().Attached) == 1 }, 5*time.Second, time.Millisecond)
224 cancel()
225 require.ErrorIs(t, <-done, context.Canceled)
226 require.True(t, display.Closed())
227
228 display2, surface2 := hudConnection(t, srv)
229 err := authorizeLayer(context.Background(), display2, surface2, testserver.Token, nil, 50*time.Millisecond)
230 require.ErrorIs(t, err, context.DeadlineExceeded)
231 require.True(t, display2.Closed())
232}
233
234func TestAuthorizeLayerRejectsBadInput(t *testing.T) {
235 require.Error(t, AuthorizeLayer(context.Background(), nil, nil, testserver.Token, nil))
236 _, srv := newSource(t, extConfig())
237 display, surface := hudConnection(t, srv)
238 require.Error(t, AuthorizeLayer(context.Background(), display, (*core.Surface)(nil), testserver.Token, nil), "typed nil surface")
239 _, other := hudConnection(t, srv)
240 require.Error(t, AuthorizeLayer(context.Background(), display, other, testserver.Token, nil), "surface of another connection")
241 for _, tok := range []string{"", "has space", testserver.Token[:63], testserver.Token + "0", testserver.Token[:63] + "G", testserver.Token[:63] + "A"} {
242 require.Error(t, AuthorizeLayer(context.Background(), display, surface, tok, nil), "%q", tok)
243 }
244 require.Empty(t, srv.Stats().Attached)
245}
246
247func TestAuthorizeLayerWithoutGlobal(t *testing.T) {
248 srv := testserver.Start(t, testserver.Config{})
249 display, surface := hudConnection(t, srv)
250 require.ErrorIs(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil), ErrExclusionUnsupported)
251}
252
253// A refused exclusion leaves none behind: the session is an ordinary one and
254// is not kept alive by the sticky-stopped rule.
255func TestRefusedExclusionDoesNotPinStoppedSession(t *testing.T) {
256 cfg := extConfig()
257 cfg.ExclusionFail = 1
258 s, srv := newSource(t, cfg)
259 target := ports.Target{OutputID: firstOutput(t, s).ID}
260 _, err := s.BeginExclusion(context.Background(), target)
261 require.ErrorIs(t, err, ErrExclusionUnavailable)
262 require.Equal(t, 1, srv.Stats().DestroyedExclusions, "the failed exclusion object is destroyed")
263 srv.StopSessions()
264 _, err = s.Capture(context.Background(), target)
265 require.ErrorIs(t, err, ports.ErrCaptureStopped)
266 _, err = s.Capture(context.Background(), target)
267 require.NoError(t, err, "a stopped session without an exclusion is replaced")
268}
269
270// The token is a secret: nothing of the exclusion flow logs it.
271func TestTokenNeverLogged(t *testing.T) {
272 var logs bytes.Buffer
273 ctx := logging.With(context.Background(), &logs, true)
274 srv := testserver.Start(t, extConfig())
275 s, err := New(ctx, srv.Path)
276 require.NoError(t, err)
277 defer s.Close()
278 target := ports.Target{OutputID: firstOutput(t, s).ID}
279 token, err := s.BeginExclusion(ctx, target)
280 require.NoError(t, err)
281 _, err = s.Capture(ctx, target)
282 require.NoError(t, err)
283 display, surface := hudConnection(t, srv)
284 require.NoError(t, AuthorizeLayer(ctx, display, surface, token, nil))
285 srv.StopSessions()
286 _, _ = s.Capture(ctx, target)
287 require.NotEmpty(t, logs.String(), "the source logs something")
288 require.NotContains(t, logs.String(), token)
289}