1package drm
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "sync/atomic"
8 "testing"
9 "time"
10
11 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12 "github.com/bnema/neferwl/internal/ports"
13 "github.com/stretchr/testify/mock"
14 "golang.org/x/sys/unix"
15)
16
17// Connector HDR property IDs and values of the amdgpu rule tests.
18const (
19 pHDRMeta, pColorspace, pMaxBPC = 20, 21, 22
20 colorspaceBT2020 = 9
21)
22
23// Blob lifetime is part of the model: attach feeds createBlob/destroyBlob
24// mocks into the live set.
25type amdgpuModel struct {
26 t *testing.T
27 mu sync.Mutex
28 live map[uint32]bool
29 next uint32
30 rule func(*atomicReq, uint32) error
31}
32
33// newAmdgpuModel starts from a lit desktop whose mode blob 99 is live.
34func newAmdgpuModel(t *testing.T) *amdgpuModel {
35 t.Helper()
36 m := &amdgpuModel{t: t, live: map[uint32]bool{99: true}, next: 100}
37 m.rule = amdgpuRuleWith(t, m.isLive)
38 return m
39}
40
41func (m *amdgpuModel) isLive(blob uint32) bool {
42 m.mu.Lock()
43 defer m.mu.Unlock()
44 return m.live[blob]
45}
46
47// attach makes k create ids from 100 up and destroy only live blobs. With
48// strict ids, each is expected to be destroyed exactly once and no other
49// destroy is allowed; without, any live blob may be destroyed.
50func (m *amdgpuModel) attach(k *mockkms, strict ...uint32) {
51 k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) {
52 m.mu.Lock()
53 defer m.mu.Unlock()
54 id := m.next
55 m.next++
56 m.live[id] = true
57 return id, nil
58 }).Maybe()
59 destroy := func(id uint32) error {
60 m.mu.Lock()
61 defer m.mu.Unlock()
62 if !m.live[id] {
63 m.t.Errorf("amdgpu: destroyBlob(%d) of an unknown or destroyed blob", id)
64 return unix.ENOENT
65 }
66 delete(m.live, id)
67 return nil
68 }
69 if len(strict) == 0 {
70 k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(destroy).Maybe()
71 }
72 for _, id := range strict {
73 k.EXPECT().destroyBlob(id).RunAndReturn(destroy).Once()
74 }
75}
76
77// amdgpuRule returns a commit rule encoding the atomic_check constraints of
78// amdgpu_dm that real hardware applied to the protected requests (EINVAL on
79// kernel 7.2, RX/DP+HDMI). The rule tracks the committed state, starting from
80// a lit desktop: CRTC enabled and active, primary/cursor/overlay/stray
81// planes attached, connector routed.
82//
83// - a CRTC with MODE_ID set (enabled) needs its primary plane attached,
84// even with ACTIVE=0 (drm_atomic_helper_check_crtc_primary_plane);
85// - MODE_ID, connector CRTC_ID and ACTIVE agree: an active or routed CRTC
86// is enabled, an enabled one is routed;
87// - a plane's FB_ID and CRTC_ID are both set or both clear, and only on an
88// enabled CRTC;
89// - the active primary plane covers the whole mode (no partial src/dst);
90// - HDR_OUTPUT_METADATA needs the BT.2020 Colorspace;
91// - MODE_ID must name a live blob, and a change of MODE_ID, ACTIVE or the
92// connector CRTC_ID is a modeset that needs the ALLOW_MODESET flag.
93//
94// amdgpuRule has no blob model: any MODE_ID is live.
95func amdgpuRule(t *testing.T) func(*atomicReq, uint32) error {
96 t.Helper()
97 return amdgpuRuleWith(t, func(uint32) bool { return true })
98}
99
100func amdgpuRuleWith(t *testing.T, live func(uint32) bool) func(*atomicReq, uint32) error {
101 t.Helper()
102 state := map[[2]uint32]uint64{
103 {tCrtc, pMode}: 99, {tCrtc, pActive}: 1, {tConn, pConnCrtc}: tCrtc,
104 }
105 for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
106 state[[2]uint32{p, pFB}], state[[2]uint32{p, pCrtcID}] = 70, tCrtc
107 }
108 return func(req *atomicReq, flags uint32) error {
109 next := make(map[[2]uint32]uint64, len(state))
110 for k, v := range state {
111 next[k] = v
112 }
113 for i, obj := range req.objs {
114 for _, p := range req.props[i] {
115 next[[2]uint32{obj, p.prop}] = p.val
116 }
117 }
118 get := func(obj, prop uint32) uint64 { return next[[2]uint32{obj, prop}] }
119 if mode := get(tCrtc, pMode); mode != 0 && !live(uint32(mode)) {
120 t.Logf("amdgpu: MODE_ID %d names an unknown or destroyed blob", mode)
121 return unix.EINVAL
122 }
123 for _, k := range [...][2]uint32{{tCrtc, pMode}, {tCrtc, pActive}, {tConn, pConnCrtc}} {
124 if next[k] != state[k] && flags&atomicAllowModes == 0 {
125 t.Logf("amdgpu: modeset change of %v without ALLOW_MODESET", k)
126 return unix.EINVAL
127 }
128 }
129 enabled, active := get(tCrtc, pMode) != 0, get(tCrtc, pActive) == 1
130 if active && !enabled || (get(tConn, pConnCrtc) != 0) != enabled {
131 t.Logf("amdgpu: ACTIVE/MODE_ID/connector routing disagree: %v", next)
132 return unix.EINVAL
133 }
134 for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
135 fb, crtc := get(p, pFB), get(p, pCrtcID)
136 if (fb != 0) != (crtc != 0) || crtc != 0 && (!enabled || crtc != tCrtc) {
137 t.Logf("amdgpu: plane %d fb=%d crtc=%d enabled=%v", p, fb, crtc, enabled)
138 return unix.EINVAL
139 }
140 }
141 if enabled && get(tPrimary, pFB) == 0 {
142 t.Log("amdgpu: enabled CRTC without primary plane")
143 return unix.EINVAL
144 }
145 if active {
146 for _, c := range [...]struct {
147 prop uint32
148 want uint64
149 }{{10, 0}, {11, 0}, {12, 200 << 16}, {13, 100 << 16}, {14, 0}, {15, 0}, {16, 200}, {17, 100}} {
150 if get(tPrimary, c.prop) != c.want {
151 t.Logf("amdgpu: primary prop %d = %d, want full-screen %d", c.prop, get(tPrimary, c.prop), c.want)
152 return unix.EINVAL
153 }
154 }
155 }
156 if get(tConn, pHDRMeta) != 0 && get(tConn, pColorspace) != colorspaceBT2020 {
157 t.Log("amdgpu: HDR metadata without BT.2020 colorspace")
158 return unix.EINVAL
159 }
160 if flags&atomicTestOnly == 0 {
161 state = next
162 }
163 return nil
164 }
165}
166
167func enableTestHDR(o *Output) {
168 o.hdr.on, o.hdr.blob = true, 55
169 o.hdr.props = connectorHDRProps{Metadata: pHDRMeta, Colorspace: pColorspace, MaxBPC: pMaxBPC, BT2020Value: colorspaceBT2020, MaxBPCValue: 8, HasDefault: true}
170}
171
172func TestAmdgpuRuleRejectsWhatHardwareRejected(t *testing.T) {
173 for name, build := range map[string]func(*atomicReq){
174 // The shape that failed on hardware: inactive, still enabled, no planes.
175 "enabled CRTC without primary": func(r *atomicReq) {
176 r.set(tCrtc, pActive, 0)
177 for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
178 r.set(p, pFB, 0)
179 r.set(p, pCrtcID, 0)
180 }
181 },
182 "plane on disabled CRTC": func(r *atomicReq) {
183 r.set(tCrtc, pActive, 0)
184 r.set(tCrtc, pMode, 0)
185 r.set(tConn, pConnCrtc, 0)
186 },
187 "connector routed to disabled CRTC": func(r *atomicReq) {
188 r.set(tCrtc, pActive, 0)
189 r.set(tCrtc, pMode, 0)
190 for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
191 r.set(p, pFB, 0)
192 r.set(p, pCrtcID, 0)
193 }
194 },
195 "cursor with CRTC but no FB": func(r *atomicReq) { r.set(tCursor, pFB, 0) },
196 "partial primary": func(r *atomicReq) { r.set(tPrimary, 16, 100) },
197 "unknown MODE_ID": func(r *atomicReq) { r.set(tCrtc, pMode, 123) },
198 "HDR metadata without colorspace": func(r *atomicReq) {
199 r.set(tConn, pHDRMeta, 55)
200 },
201 } {
202 t.Run(name, func(t *testing.T) {
203 req := &atomicReq{}
204 build(req)
205 if err := amdgpuRule(t)(req, atomicAllowModes); err == nil {
206 t.Fatal("rule accepted a request amdgpu refuses")
207 }
208 })
209 }
210}
211
212// Every protected disable/activation request must pass the amdgpu rules, with
213// HDR on and off, and leave the state dark then black-only.
214func TestProtectedRequestsAcceptedByAmdgpuRules(t *testing.T) {
215 for _, hdr := range []bool{false, true} {
216 name := map[bool]string{false: "sdr", true: "hdr"}[hdr]
217 t.Run(name, func(t *testing.T) {
218 m := newAmdgpuModel(t)
219 o, k, commits, _ := testOutputRules(t, m.rule)
220 m.attach(k, 99)
221 if hdr {
222 enableTestHDR(o)
223 }
224 o.modeBlob = 99
225 o.overlay = &plane{id: tOverlay, props: planeProps}
226 o.stray = []*plane{o.overlay, {id: 53, props: planeProps}}
227 k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil)
228 r := portsmocks.NewMockRenderer(t)
229 r.EXPECT().UseTarget(0).Return().Once()
230 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
231 if err := o.protectedModeset(context.Background(), r); err != nil {
232 t.Fatalf("amdgpu refused the protected transaction: %v", err)
233 }
234 if len(*commits) != 2 {
235 t.Fatalf("commits %d", len(*commits))
236 }
237 act := (*commits)[1]
238 if fb, _ := act.req.value(tPrimary, pFB); fb != 71 && fb != 70 {
239 t.Fatalf("activated fb %d", fb)
240 }
241 if crtc, ok := act.req.value(tCursor, pCrtcID); !ok || crtc != 0 {
242 t.Fatalf("cursor not explicitly detached: %d %v", crtc, ok)
243 }
244 if fb, ok := act.req.value(tCursor, pFB); !ok || fb != 0 {
245 t.Fatalf("cursor fb not explicitly detached: %d %v", fb, ok)
246 }
247 if o.off || !o.protected {
248 t.Fatalf("off=%v protected=%v", o.off, o.protected)
249 }
250 // The desktop mode blob is destroyed once by the disable; the
251 // activation's blob is the only live one and o.modeBlob owns it.
252 if o.modeBlob != 100 || !m.isLive(100) || m.isLive(99) {
253 t.Fatalf("modeBlob %d live100=%v live99=%v", o.modeBlob, m.isLive(100), m.isLive(99))
254 }
255 })
256 }
257}
258
259func TestAmdgpuRuleRejectsModesetWithoutAllowModes(t *testing.T) {
260 for name, build := range map[string]func(*atomicReq){
261 "MODE_ID": func(r *atomicReq) { r.set(tCrtc, pMode, 99) },
262 "ACTIVE": func(r *atomicReq) { r.set(tCrtc, pActive, 0) },
263 "connector CRTC_ID": func(r *atomicReq) { r.set(tConn, pConnCrtc, 0) },
264 } {
265 t.Run(name, func(t *testing.T) {
266 m := newAmdgpuModel(t)
267 m.live[98] = true
268 req := &atomicReq{}
269 build(req)
270 if name == "MODE_ID" {
271 req.set(tCrtc, pMode, 98)
272 }
273 if err := m.rule(req, 0); err == nil {
274 t.Fatal("rule accepted a modeset change without ALLOW_MODESET")
275 }
276 })
277 }
278}
279
280func TestAmdgpuRuleRejectsDestroyedModeBlob(t *testing.T) {
281 m := newAmdgpuModel(t)
282 req := &atomicReq{}
283 req.set(tCrtc, pMode, 99)
284 for _, c := range [...]struct{ prop, val uint32 }{{12, 200 << 16}, {13, 100 << 16}, {16, 200}, {17, 100}} {
285 req.set(tPrimary, c.prop, uint64(c.val))
286 }
287 if err := m.rule(req, atomicAllowModes); err != nil {
288 t.Fatalf("live blob rejected: %v", err)
289 }
290 m.mu.Lock()
291 delete(m.live, 99)
292 m.mu.Unlock()
293 if err := m.rule(req, atomicAllowModes); err == nil {
294 t.Fatal("rule accepted a destroyed MODE_ID blob")
295 }
296}
297
298func TestProtectedResumeAcceptedByAmdgpuRules(t *testing.T) {
299 m := newAmdgpuModel(t)
300 o, k, commits, _ := testOutputRules(t, m.rule)
301 m.attach(k)
302 o.modeBlob = 99
303 o.protected = true
304 o.overlay = &plane{id: tOverlay, props: planeProps}
305 o.stray = []*plane{o.overlay, {id: 53, props: planeProps}}
306 k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil)
307 if err := o.modeset(); err != nil { // resume while protected: dark, not stale
308 t.Fatalf("amdgpu refused protected inactive modeset: %v", err)
309 }
310 if !o.off || len(*commits) != 1 {
311 t.Fatalf("off=%v commits=%d", o.off, len(*commits))
312 }
313 if v, ok := (*commits)[0].req.value(tCrtc, pMode); !ok || v != 0 {
314 t.Fatal("protected disable left the CRTC enabled")
315 }
316}
317
318// The legacy request is refused by the rule; the output stays registered and
319// protected, retries with bounded backoff and never reports proof.
320func TestProtectedRefusalKeepsOutputProtectedAndBacksOff(t *testing.T) {
321 o, k, commits, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error {
322 if v, ok := r.value(tCrtc, pActive); ok && v == 1 {
323 return unix.EINVAL
324 }
325 return nil
326 })
327 k.EXPECT().createBlob(mock.Anything).Return(100, nil)
328 k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
329 r := portsmocks.NewMockRenderer(t)
330 r.EXPECT().UseTarget(0).Return().Once()
331 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
332 err := o.protectedModeset(context.Background(), r)
333 if err == nil || !retryableProtected(err) || !errorsIsEINVAL(err) {
334 t.Fatalf("refusal result %v", err)
335 }
336 if !o.protected || !o.off || o.back != 0 || len(*commits) != 2 {
337 t.Fatalf("protected=%v off=%v back=%d commits=%d", o.protected, o.off, o.back, len(*commits))
338 }
339 var enabled = true
340 if !o.commitFailed(err, &enabled) || !enabled || !o.protectBackoffActive() {
341 t.Fatal("refusal not retryable with backoff")
342 }
343 d := time.Duration(0)
344 for range 10 {
345 d = nextProtectBackoff(d)
346 }
347 if d != protectRetryMax || nextProtectBackoff(0) != protectRetryMin {
348 t.Fatalf("backoff not bounded: %v", d)
349 }
350}
351
352func errorsIsEINVAL(err error) bool { return err != nil && errors.Is(err, unix.EINVAL) }
353
354// Run: activation refused twice with EINVAL. The output keeps running, stays
355// dark, and only the later accepted black activation produces proof.
356func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T) {
357 var activations, refusals atomic.Int32
358 o, k, _, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error {
359 if v, ok := r.value(tCrtc, pActive); ok && v == 1 && flags&atomicTestOnly == 0 {
360 activations.Add(1)
361 if refusals.Add(1) <= 2 {
362 return unix.EINVAL
363 }
364 }
365 return nil
366 })
367 o.cursor = nil
368 var state atomic.Uint64
369 state.Store(3)
370 securityGate(t, o, &state)
371 events := make(chan ports.SecurityBackendEvent, 32)
372 o.SecurityEvents = events
373 r := portsmocks.NewMockRenderer(t)
374 buf := func() ports.DMABuf {
375 f, w := protectionFence(t, false)
376 w.Close()
377 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
378 }
379 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
380 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
381 r.EXPECT().UseTarget(mock.Anything).Return()
382 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
383 r.EXPECT().Close().Return().Once()
384 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
385 k.EXPECT().createBlob(mock.Anything).Return(99, nil)
386 k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
387 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
388 defer cancel()
389 done := make(chan error, 1)
390 go func() {
391 done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, make(chan ports.OutputPresented, 8), nil, nil)
392 }()
393 for {
394 select {
395 case ev := <-events:
396 if _, ok := ev.(ports.SecurityOutputProof); !ok {
397 continue
398 }
399 if n := activations.Load(); n < 3 {
400 t.Fatalf("proof after %d activation attempts; refused ones are no evidence", n)
401 }
402 cancel()
403 if err := <-done; err != nil {
404 t.Fatalf("output stopped: %v", err)
405 }
406 return
407 case err := <-done:
408 t.Fatalf("output stopped on refused protected commit: %v", err)
409 case <-ctx.Done():
410 t.Fatal("no proof after retries")
411 }
412 }
413}