1package drm
2
3import (
4 "context"
5 "errors"
6 "os"
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// A generated mock through the real ports interface, using a coherent atomic
18// test state so transitions can race with the output owner without data races.
19func securityGate(t *testing.T, o *Output, state *atomic.Uint64) {
20 k := portsmocks.NewMockSessionSecurity(t)
21 k.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
22 v := state.Load()
23 return ports.SecurityState{Generation: ports.LockGeneration(v >> 1), Protected: v&1 != 0}
24 })
25 o.Security = k
26 o.Instance = 77
27 o.SecurityEvents = make(chan ports.SecurityBackendEvent, 8)
28}
29
30func TestSecurityPreparationProofAndFailure(t *testing.T) {
31 for _, stage := range []string{"success", "off", "disable", "render", "pending", "changed"} {
32 t.Run(stage, func(t *testing.T) {
33 var failures []error
34 if stage == "disable" {
35 failures = []error{unix.EACCES}
36 }
37 o, k, commits := testOutput(t, failures...)
38 var state atomic.Uint64
39 state.Store(3)
40 securityGate(t, o, &state)
41 events := make(chan ports.SecurityBackendEvent, 8)
42 o.SecurityEvents = events
43 r := portsmocks.NewMockRenderer(t)
44 if stage == "pending" {
45 f, _ := protectionFence(t, false)
46 o.frame.begin(pendingFrame{fences: []*os.File{f}}, time.Now())
47 } else if stage != "disable" {
48 r.EXPECT().UseTarget(0).Return().Once()
49 r.EXPECT().Render(ports.Scene{Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
50 if stage == "changed" {
51 state.Store(5)
52 }
53 if stage == "render" {
54 return nil, errors.New("render refused")
55 }
56 return nil, nil
57 }).Once()
58 if stage != "off" && stage != "render" {
59 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
60 }
61 }
62 o.wantOff = stage == "off"
63 err := o.prepareSecurity(context.Background(), r)
64 wantErr := stage == "disable" || stage == "render" || stage == "pending"
65 if (err != nil) != wantErr {
66 t.Fatalf("result %v", err)
67 }
68 if stage == "success" || stage == "off" {
69 select {
70 case ev := <-events:
71 proof, ok := ev.(ports.SecurityOutputProof)
72 kind := ports.ProtectionProtectedFrame
73 if stage == "off" {
74 kind = ports.ProtectionInactiveOutput
75 }
76 if !ok || proof.Proof != (ports.OutputProtection{Generation: 1, Instance: 77, Kind: kind}) {
77 t.Fatalf("proof %+v", ev)
78 }
79 default:
80 t.Fatal("missing proof")
81 }
82 } else if len(events) != 0 || o.securityPrepared {
83 t.Fatal("failure or changed epoch certified protection")
84 }
85 if stage == "pending" && len(*commits) != 0 {
86 t.Fatal("pending client fence queued blocking KMS")
87 }
88 })
89 }
90}
91
92func TestSecurityRejectsStaleAcquireReleaseScenes(t *testing.T) {
93 o, _, commits := testOutput(t)
94 var state atomic.Uint64
95 securityGate(t, o, &state)
96 r := portsmocks.NewMockRenderer(t)
97 state.Store(3)
98 if _, err := o.submitFrame(context.Background(), r, ports.Scene{}, nil, nil, nil, nil); !errors.Is(err, errSecurityScene) {
99 t.Fatalf("old desktop admitted: %v", err)
100 }
101 state.Store(4)
102 if _, err := o.submitFrame(context.Background(), r, ports.Scene{Security: ports.SecurityState{Generation: 1, Protected: true}}, nil, nil, nil, nil); !errors.Is(err, errSecurityScene) {
103 t.Fatalf("old lock admitted on release: %v", err)
104 }
105 if len(*commits) != 0 {
106 t.Fatal("stale epoch committed")
107 }
108}
109
110func TestSecurityProtectedCompositionDetachesCursorAndOverlay(t *testing.T) {
111 o, _, commits := testOutput(t)
112 var state atomic.Uint64
113 state.Store(3)
114 securityGate(t, o, &state)
115 o.observeSecurity()
116 o.securityPrepared = true
117 o.cursor.image = true
118 o.cursor.Move(8, 8)
119 o.overlay = &plane{id: tOverlay, props: planeProps}
120 s, c := fullscreenScene()
121 s.Security = ports.SecurityState{Generation: 1, Protected: true}
122 s.CaptureScene = &ports.Scene{}
123 r := portsmocks.NewMockRenderer(t)
124 r.EXPECT().UseTarget(0).Return().Once()
125 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(scene ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
126 if scene.CaptureScene != nil {
127 t.Fatal("hidden capture crossed protected path")
128 }
129 return nil, nil
130 }).Once()
131 direct, err := o.submitFrame(context.Background(), r, s, c, nil, nil, nil)
132 if err != nil || direct {
133 t.Fatalf("direct=%v err=%v", direct, err)
134 }
135 last := (*commits)[0]
136 if last.flags&flipAsyncFlag != 0 {
137 t.Fatal("protected tearing")
138 }
139 for _, plane := range []uint32{tCursor, tOverlay} {
140 if fb, _ := last.req.value(plane, pFB); fb != 0 {
141 t.Fatalf("desktop plane %d attached", plane)
142 }
143 }
144}
145
146func TestSecurityCloseReadsGateAfterOwnerStopped(t *testing.T) {
147 o, k, commits := testOutput(t)
148 var state atomic.Uint64
149 securityGate(t, o, &state)
150 o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode}
151 state.Store(3) // no wake or owner loop observes it
152 k.EXPECT().rmFB(mock.Anything).Return(nil)
153 o.Close()
154 checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor)
155}
156
157func TestSecurityCommitStateReadsGateBeforeCursorCommit(t *testing.T) {
158 o, _, commits := testOutput(t)
159 var state atomic.Uint64
160 securityGate(t, o, &state)
161 o.cursor.image = true
162 o.cursor.Move(10, 10)
163 state.Store(3)
164 if err := o.commitState(true); !errors.Is(err, errSecurityScene) {
165 t.Fatalf("cursor result: %v", err)
166 }
167 if len(*commits) != 0 || !o.protected {
168 t.Fatal("cursor committed before owner observed lock")
169 }
170}