1package app
2
3import (
4 "context"
5 "reflect"
6 "testing"
7 "time"
8
9 "github.com/bnema/neferwl/internal/adapters/wayland/sessionlock"
10 "github.com/bnema/neferwl/internal/ports"
11)
12
13func hotplugEvent(t *testing.T, events <-chan ports.SecurityBackendEvent) ports.SecurityBackendEvent {
14 t.Helper()
15 select {
16 case event := <-events:
17 return event
18 case <-time.After(2 * time.Second):
19 t.Fatal("missing ordered security event")
20 return nil
21 }
22}
23
24// This switch represents the single display owner's ledger consumption, not
25// an alternate backend implementation. Events come through real outputSet FIFO.
26func hotplugApply(ledger *sessionlock.Readiness, event ports.SecurityBackendEvent) bool {
27 switch v := event.(type) {
28 case ports.SecurityOutputAdded:
29 return ledger.Add(v.Instance)
30 case ports.SecurityOutputRemoved:
31 return ledger.Remove(v.Instance)
32 case ports.SecurityOutputInvalidated:
33 return ledger.Invalidate(v.Generation, v.Instance)
34 case ports.SecurityOutputProof:
35 return ledger.Record(v.Proof)
36 case ports.SecurityBackendBarrier:
37 return v.Err == nil && ledger.BackendBarrier(v.Generation)
38 default:
39 return false
40 }
41}
42
43func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) {
44 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
45 defer cancel()
46 events := make(chan ports.SecurityBackendEvent, 8)
47 set := newOutputSet(ctx, nil)
48 set.wireSecurity(outputChannels{securityEvents: events})
49 defer func() {
50 if err := set.wait(); err != nil {
51 t.Error(err)
52 }
53 }()
54 const generation = ports.LockGeneration(41)
55 set.setSecurity(ports.SecurityState{Generation: generation, Protected: true})
56 var ledger sessionlock.Readiness
57 if !ledger.Begin(generation) || !ledger.BackendBarrier(generation) || !ledger.CaptureBarrier(generation) {
58 t.Fatal("could not begin display readiness round")
59 }
60 // Native Close success is independently tested in DRM. Here the real
61 // owner callback synchronizes that affirmative result before the backend
62 // sends removal; mere callback exit never emits removal from outputSet.
63 start := func(closeSuccess bool) (ports.OutputInstance, chan []ports.SecurityBackendEvent, <-chan bool) {
64 t.Helper()
65 commands := make(chan []ports.SecurityBackendEvent, 1)
66 closed := make(chan bool, 1)
67 var ownerEvents chan<- ports.SecurityBackendEvent
68 if !set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {
69 defer func() { closed <- closeSuccess }()
70 state := <-security
71 proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}
72 select {
73 case ownerEvents <- proof:
74 case <-octx.Done():
75 return nil
76 }
77 for {
78 select {
79 case batch := <-commands:
80 for _, event := range batch {
81 select {
82 case ownerEvents <- event:
83 case <-octx.Done():
84 return nil
85 }
86 }
87 case <-octx.Done():
88 return nil
89 }
90 }
91 }, &ownerEvents) {
92 t.Fatal("start refused")
93 }
94 registered := hotplugEvent(t, events)
95 added, ok := registered.(ports.SecurityOutputAdded)
96 if !ok || added.Output != "DP-1" || added.Instance == 0 || !hotplugApply(&ledger, registered) {
97 t.Fatalf("owner evidence preceded lifetime registration: %+v", registered)
98 }
99 if ledger.Ready() {
100 t.Fatal("new lifetime reused previous physical readiness")
101 }
102 proof := hotplugEvent(t, events)
103 if !hotplugApply(&ledger, proof) || !ledger.Ready() {
104 t.Fatalf("missing exact lifetime proof: %+v", proof)
105 }
106 return added.Instance, commands, closed
107 }
108 old, commands, closed := start(true)
109 // A proof already queued by this owner precedes invalidation, even if the
110 // display reads later. No separate forwarder can replay it after revoke.
111 oldProof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: old, Kind: ports.ProtectionProtectedFrame}}
112 invalid := ports.SecurityOutputInvalidated{Generation: generation, Instance: old}
113 commands <- []ports.SecurityBackendEvent{oldProof, invalid}
114 if got := hotplugEvent(t, events); got != oldProof || !hotplugApply(&ledger, got) {
115 t.Fatalf("queued proof lost: %+v", got)
116 }
117 if got := hotplugEvent(t, events); got != invalid || !hotplugApply(&ledger, got) || ledger.Ready() {
118 t.Fatalf("invalidation reordered or ineffective: %+v", got)
119 }
120 commands <- []ports.SecurityBackendEvent{oldProof}
121 if got := hotplugEvent(t, events); got != oldProof || !hotplugApply(&ledger, got) || !ledger.Ready() {
122 t.Fatal("fresh resume proof did not restore readiness")
123 }
124 set.outs["DP-1"].stop()
125 select {
126 case stopped := <-set.stopped:
127 if stopped.instance != old || !set.stoppedCurrent(stopped) {
128 t.Fatalf("wrong stopped lifetime: %+v", stopped)
129 }
130 case <-ctx.Done():
131 t.Fatal("owner did not stop")
132 }
133 if err := set.finish("DP-1"); err != nil {
134 t.Fatal(err)
135 }
136 if !<-closed {
137 t.Fatal("shutdown not affirmative")
138 }
139 select {
140 case got := <-events:
141 t.Fatalf("owner exit forged removal: %+v", got)
142 default:
143 }
144 if !set.securityEvent(ports.SecurityOutputRemoved{Instance: old}) {
145 t.Fatal("confirmed removal refused")
146 }
147 removal := hotplugEvent(t, events)
148 if removal != (ports.SecurityOutputRemoved{Instance: old}) || !hotplugApply(&ledger, removal) {
149 t.Fatalf("wrong removal: %+v", removal)
150 }
151 replacement, _, replacementClosed := start(false)
152 if replacement <= old || set.stoppedCurrent(outputStopped{name: "DP-1", instance: old}) {
153 t.Fatal("same-name replacement reused retired lifetime")
154 }
155 if hotplugApply(&ledger, oldProof) || hotplugApply(&ledger, invalid) {
156 t.Fatal("retired lifetime affected replacement")
157 }
158 stale := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation - 1, Instance: replacement, Kind: ports.ProtectionInactiveOutput}}
159 if hotplugApply(&ledger, stale) || !ledger.Ready() {
160 t.Fatal("stale generation overwrote valid replacement proof")
161 }
162 if !ledger.Invalidate(generation, replacement) || ledger.Ready() {
163 t.Fatal("replacement invalidation ineffective")
164 }
165 set.outs["DP-1"].stop()
166 select {
167 case <-set.stopped:
168 case <-ctx.Done():
169 t.Fatal("replacement stop blocked")
170 }
171 if err := set.finish("DP-1"); err != nil {
172 t.Fatal(err)
173 }
174 if <-replacementClosed {
175 t.Fatal("expected failed shutdown")
176 }
177 select {
178 case got := <-events:
179 t.Fatalf("failed shutdown forged removal: %+v", got)
180 default:
181 }
182 if ledger.Ready() {
183 t.Fatal("failed replacement exit erased required unproved lifetime")
184 }
185}
186
187func TestSecurityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) {
188 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
189 defer cancel()
190 events := make(chan ports.SecurityBackendEvent) // block display after registration
191 set := newOutputSet(ctx, nil)
192 set.wireSecurity(outputChannels{securityEvents: events})
193 defer func() {
194 if err := set.wait(); err != nil {
195 t.Error(err)
196 }
197 }()
198 const generation = ports.LockGeneration(7)
199 accepted := make(chan int, 40)
200 startDone := make(chan bool, 1)
201 var ownerEvents chan<- ports.SecurityBackendEvent
202 go func() {
203 startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
204 for i := range 40 {
205 var event ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: instance}
206 if i%2 == 0 {
207 event = ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}
208 }
209 select {
210 case ownerEvents <- event:
211 accepted <- i
212 case <-octx.Done():
213 return nil
214 }
215 }
216 return nil
217 }, &ownerEvents)
218 }()
219 registered := hotplugEvent(t, events).(ports.SecurityOutputAdded)
220 if !<-startDone {
221 t.Fatal("start failed")
222 }
223 if cap(set.outs["DP-1"].securityEvents) != 16 {
224 t.Fatal("owner evidence FIFO is not bounded to 16")
225 }
226 // Forwarder holds one event plus its 16-slot input. The eighteenth send
227 // must remain blocked until the display starts draining.
228 for i := range 17 {
229 select {
230 case got := <-accepted:
231 if got != i {
232 t.Fatalf("acceptance order %d want %d", got, i)
233 }
234 case <-ctx.Done():
235 t.Fatal("FIFO failed to fill")
236 }
237 }
238 select {
239 case got := <-accepted:
240 t.Fatalf("full FIFO did not backpressure owner: %d", got)
241 case <-time.After(20 * time.Millisecond):
242 }
243 var ledger sessionlock.Readiness
244 ledger.Add(registered.Instance)
245 ledger.Begin(generation)
246 ledger.BackendBarrier(generation)
247 ledger.CaptureBarrier(generation)
248 for i := range 40 {
249 got := hotplugEvent(t, events)
250 var want ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: registered.Instance}
251 if i%2 == 0 {
252 want = ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: registered.Instance, Kind: ports.ProtectionProtectedFrame}}
253 }
254 if !reflect.DeepEqual(got, want) || !hotplugApply(&ledger, got) || ledger.Ready() != (i%2 == 0) {
255 t.Fatalf("evidence lost/duplicated/reordered at %d: %+v", i, got)
256 }
257 }
258 select {
259 case stopped := <-set.stopped:
260 if !set.stoppedCurrent(stopped) {
261 t.Fatal("stop not current")
262 }
263 case <-ctx.Done():
264 t.Fatal("normal owner stop blocked")
265 }
266 if err := set.finish("DP-1"); err != nil {
267 t.Fatal(err)
268 }
269 select {
270 case got := <-events:
271 t.Fatalf("duplicate evidence or removal after normal drain: %+v", got)
272 default:
273 }
274}
275
276// All producer evidence is already buffered when the owner stops normally.
277// finish must not cancel that owner's forwarder or lose its outstanding FIFO.
278func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) {
279 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
280 defer cancel()
281 events := make(chan ports.SecurityBackendEvent)
282 set := newOutputSet(ctx, nil)
283 set.wireSecurity(outputChannels{securityEvents: events})
284 defer func() {
285 if err := set.wait(); err != nil {
286 t.Error(err)
287 }
288 }()
289 startDone := make(chan bool, 1)
290 var ownerEvents chan<- ports.SecurityBackendEvent
291 go func() {
292 startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
293 for i := range 16 {
294 event := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: instance}
295 select {
296 case ownerEvents <- event:
297 case <-octx.Done():
298 return nil
299 }
300 }
301 return nil
302 }, &ownerEvents)
303 }()
304 registered := hotplugEvent(t, events).(ports.SecurityOutputAdded)
305 if !<-startDone {
306 t.Fatal("start refused")
307 }
308 select {
309 case stopped := <-set.stopped:
310 if stopped.instance != registered.Instance {
311 t.Fatal("wrong stopped lifetime")
312 }
313 case <-ctx.Done():
314 t.Fatal("buffered owner did not finish")
315 }
316 if err := set.finish("DP-1"); err != nil {
317 t.Fatal(err)
318 }
319 for i := range 16 {
320 want := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: registered.Instance}
321 if got := hotplugEvent(t, events); got != want {
322 t.Fatalf("buffered stopped-owner evidence %d: %+v", i, got)
323 }
324 }
325 select {
326 case got := <-events:
327 t.Fatalf("duplicate buffered evidence: %+v", got)
328 default:
329 }
330}
331
332func TestSecurityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) {
333 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
334 defer cancel()
335 events := make(chan ports.SecurityBackendEvent)
336 set := newOutputSet(ctx, nil)
337 set.wireSecurity(outputChannels{securityEvents: events})
338 accepted := make(chan int, 40)
339 ownerDone := make(chan struct{})
340 startDone := make(chan bool, 1)
341 var ownerEvents chan<- ports.SecurityBackendEvent
342 go func() {
343 startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
344 defer close(ownerDone)
345 for i := range 40 {
346 select {
347 case ownerEvents <- ports.SecurityOutputInvalidated{Generation: 7, Instance: instance}:
348 accepted <- i
349 case <-octx.Done():
350 return nil
351 }
352 }
353 return nil
354 }, &ownerEvents)
355 }()
356 hotplugEvent(t, events) // registered before producer starts
357 if !<-startDone {
358 t.Fatal("start failed")
359 }
360 for range 17 {
361 select {
362 case <-accepted:
363 case <-ctx.Done():
364 t.Fatal("FIFO never filled")
365 }
366 }
367 select {
368 case <-accepted:
369 t.Fatal("producer did not block at finite FIFO")
370 case <-time.After(20 * time.Millisecond):
371 }
372 waitDone := make(chan error, 1)
373 go func() { waitDone <- set.wait() }()
374 select {
375 case err := <-waitDone:
376 if err != nil {
377 t.Fatal(err)
378 }
379 case <-ctx.Done():
380 t.Fatal("saturated evidence leaked owner/forwarder on cancellation")
381 }
382 select {
383 case <-ownerDone:
384 default:
385 t.Fatal("wait returned before producer exited")
386 }
387 if len(set.outs) != 0 {
388 t.Fatal("stopped lifetime retained in running set")
389 }
390 // wait includes forwarders.Wait: termination is directly synchronized,
391 // rather than guessed from goroutine counts or arbitrary sleeps.
392}