1package headless
2
3import (
4 "context"
5 "errors"
6 "image"
7 "os"
8 "path/filepath"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "github.com/bnema/neferwl/internal/adapters/logging"
14 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
15 "github.com/bnema/neferwl/internal/ports"
16 "github.com/stretchr/testify/mock"
17)
18
19func securityReceive[T any](t *testing.T, ch <-chan T) T {
20 t.Helper()
21 select {
22 case v := <-ch:
23 return v
24 case <-time.After(time.Second):
25 t.Fatal("timed out")
26 var z T
27 return z
28 }
29}
30
31func TestSecurityInitialBlackFenceBeforeLockerAndNoReadback(t *testing.T) {
32 state := ports.SecurityState{Generation: 7, Protected: true}
33 gate := portsmocks.NewMockSessionSecurity(t)
34 gate.EXPECT().Snapshot().Return(state)
35 r := portsmocks.NewMockRenderer(t)
36 r.EXPECT().Close().Return().Once()
37 fence, signal, err := os.Pipe()
38 if err != nil {
39 t.Fatal(err)
40 }
41 defer signal.Close()
42 rendered := make(chan ports.Scene, 4)
43 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
44 if s.Seq == 0 {
45 if s.Background != "#000000" || s.Security != state || len(s.Windows) != 0 || len(s.Layers) != 0 || s.Capture != nil || s.CaptureScene != nil || c != nil {
46 t.Errorf("unsafe initial black: %+v surfaces %v", s, c)
47 }
48 rendered <- s
49 return fence, nil
50 }
51 if s.Security != state || s.Seq != 2 {
52 t.Errorf("stale desktop rendered: %+v", s)
53 }
54 rendered <- s
55 return nil, nil
56 }).Twice()
57 // No Pixels/BeginCapture expectation: either call fails this test.
58 scenes := make(chan ports.Scene, 3)
59 scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}}
60 scenes <- ports.Scene{Security: state, Seq: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 10, Fullscreen: true, Rect: ports.Rect{W: 2, H: 2}}}}
61 contents := newContentFeed(t, 1)
62 acquire, acquireSignal, err := os.Pipe()
63 if err != nil {
64 t.Fatal(err)
65 }
66 defer acquire.Close()
67 defer acquireSignal.Close()
68 contents.send(ports.SurfaceContent{ID: 10, SHM: &ports.SHMBuffer{Pool: 10}, Acquire: acquire})
69 proof := make(chan ports.SecurityBackendEvent, 4)
70 replies := make(chan ports.CaptureDone, 4)
71 requests := make(chan ports.CaptureRequest, 2)
72 dst, err := os.CreateTemp(t.TempDir(), "capture")
73 if err != nil {
74 t.Fatal(err)
75 }
76 requests <- ports.CaptureRequest{ID: 44, Dst: ports.SHMBuffer{File: dst}}
77 ctx, cancel := context.WithCancel(context.Background())
78 done := make(chan error, 1)
79 dir := t.TempDir()
80 go func() {
81 done <- Run(ctx, Options{Security: gate, Instance: 9, SecurityEvents: proof, Width: 2, Height: 2, Name: "H", ScreenshotDir: dir, Cursor: &Cursor{}, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, requests)
82 }()
83 t.Cleanup(func() {
84 cancel()
85 if err := securityReceive(t, done); err != nil {
86 t.Error(err)
87 }
88 })
89 securityReceive(t, rendered)
90 select {
91 case p := <-proof:
92 t.Fatalf("proof before fence: %+v", p)
93 default:
94 }
95 if _, err := signal.Write([]byte{1}); err != nil {
96 t.Fatal(err)
97 }
98 p := securityReceive(t, proof).(ports.SecurityOutputProof)
99 if p.Proof != (ports.OutputProtection{Generation: 7, Instance: 9, Kind: ports.ProtectionProtectedFrame}) {
100 t.Fatal(p)
101 }
102 securityReceive(t, rendered)
103 result := securityReceive(t, replies)
104 if result.ID != 44 || result.Err == nil {
105 t.Fatal(result)
106 }
107 if _, err := dst.Stat(); err == nil {
108 t.Fatal("capture destination not closed")
109 }
110 if err := dst.Close(); err == nil {
111 t.Fatal("destination was still open")
112 }
113 select {
114 case result := <-replies:
115 t.Fatalf("duplicate failure: %+v", result)
116 default:
117 }
118 files, err := os.ReadDir(dir)
119 if err != nil || len(files) != 0 {
120 t.Fatalf("protected screenshot files: %v %v", files, err)
121 }
122}
123
124func TestSecurityChangesWakeIdleAndRejectQueuedDesktop(t *testing.T) {
125 var state atomic.Value
126 state.Store(ports.SecurityState{})
127 gate := portsmocks.NewMockSessionSecurity(t)
128 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
129 r := portsmocks.NewMockRenderer(t)
130 r.EXPECT().Close().Return().Once()
131 rendered := make(chan ports.Scene, 4)
132 desktopFinished := make(chan struct{})
133 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
134 if s.Seq != 0 {
135 rendered <- s
136 <-desktopFinished
137 return nil, nil
138 }
139 if len(s.Windows) != 0 || c != nil || !s.Security.Protected {
140 t.Errorf("bad transition black %+v", s)
141 }
142 rendered <- s
143 return nil, nil
144 }).Twice()
145 scenes := make(chan ports.Scene, 2)
146 scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}}
147 changes := make(chan ports.SecurityState, 1)
148 proofs := make(chan ports.SecurityBackendEvent, 2)
149 sink, presented := presentedSink(t, 4)
150 ctx, cancel := context.WithCancel(context.Background())
151 done := make(chan error, 1)
152 go func() {
153 done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 3, SecurityEvents: proofs, Presented: sink, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
154 }()
155 t.Cleanup(func() {
156 cancel()
157 if err := securityReceive(t, done); err != nil {
158 t.Error(err)
159 }
160 })
161 securityReceive(t, rendered)
162 locked := ports.SecurityState{Generation: 1, Protected: true}
163 state.Store(locked)
164 scenes <- ports.Scene{Seq: 99, Windows: []ports.SceneWindow{{ID: 1}}}
165 changes <- locked
166 close(desktopFinished)
167 s := securityReceive(t, rendered)
168 if s.Security != locked || s.Seq != 0 {
169 t.Fatal(s)
170 }
171 securityReceive(t, proofs)
172 select {
173 case p := <-presented:
174 if p.Flip != nil {
175 t.Fatalf("stale render reported: %+v", p)
176 }
177 default:
178 }
179}
180
181func TestSecurityDedicatedChangeWakesWithoutScene(t *testing.T) {
182 var state atomic.Value
183 state.Store(ports.SecurityState{})
184 gate := portsmocks.NewMockSessionSecurity(t)
185 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
186 r := portsmocks.NewMockRenderer(t)
187 r.EXPECT().Close().Return().Once()
188 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
189 changes := make(chan ports.SecurityState, 1)
190 proof := make(chan ports.SecurityBackendEvent, 1)
191 ctx, cancel := context.WithCancel(context.Background())
192 done := make(chan error, 1)
193 go func() {
194 done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 5, SecurityEvents: proof, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, nil)
195 }()
196 locked := ports.SecurityState{Generation: 2, Protected: true}
197 state.Store(locked)
198 changes <- locked
199 securityReceive(t, proof)
200 cancel()
201 if err := securityReceive(t, done); err != nil {
202 t.Fatal(err)
203 }
204}
205
206func TestSecurityCursorAndPNGSuppressed(t *testing.T) {
207 gate := portsmocks.NewMockSessionSecurity(t)
208 state := ports.SecurityState{Generation: 1, Protected: true}
209 gate.EXPECT().Snapshot().Return(state)
210 c := &Cursor{}
211 c.set(ports.CursorImage{W: 1, H: 1, Pixels: []byte{0, 0, 255, 255}})
212 c.Move(0, 0)
213 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
214 c.drawSecure(img, gate)
215 if img.Pix[0] != 0 {
216 t.Fatal("protected cursor drawn")
217 }
218 path := filepath.Join(t.TempDir(), "shot.png")
219 if err := writePNGSecure(path, img, gate, ports.SecurityState{}); err != nil {
220 t.Fatal(err)
221 }
222 if _, err := os.Stat(path); !os.IsNotExist(err) {
223 t.Fatal("stale screenshot delivered")
224 }
225}
226
227// The raw HDR file follows the screenshot security gating: nothing is read
228// back or written for a protected session, none is read back once the
229// session turned protected after latest.png, and a frame that crossed a
230// transition during the readback is not written.
231func TestSecurityRawHDRSuppressed(t *testing.T) {
232 unprotected := ports.SecurityState{Generation: 1}
233 protected := ports.SecurityState{Generation: 2, Protected: true}
234 tests := []struct {
235 name string
236 // hdrPixels: HDRPixels is expected and flips the session protected.
237 hdrPixels bool
238 // flipAfterLatest: the session turns protected once latest.png exists.
239 flipAfterLatest bool
240 startProtected bool
241 wantLatest bool
242 }{
243 {name: "protected from the start", startProtected: true},
244 {name: "protected after latest.png, before the readback", flipAfterLatest: true, wantLatest: true},
245 {name: "protected during the readback", hdrPixels: true, wantLatest: true},
246 }
247 for _, tc := range tests {
248 t.Run(tc.name, func(t *testing.T) {
249 dir := t.TempDir()
250 var state atomic.Value
251 state.Store(unprotected)
252 if tc.startProtected {
253 state.Store(protected)
254 }
255 gate := portsmocks.NewMockSessionSecurity(t)
256 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
257 if tc.flipAfterLatest {
258 if _, err := os.Stat(filepath.Join(dir, "latest.png")); err == nil {
259 state.Store(protected)
260 }
261 }
262 return state.Load().(ports.SecurityState)
263 })
264 f, err := os.CreateTemp(t.TempDir(), "target")
265 if err != nil {
266 t.Fatal(err)
267 }
268 r := portsmocks.NewMockRenderer(t)
269 r.EXPECT().SetHDR(float64(203)).Return().Once()
270 r.EXPECT().ExportTargets(1, []uint64(nil), false).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
271 rendered := make(chan ports.Scene, 4)
272 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
273 rendered <- s
274 return nil, nil
275 }).Maybe()
276 if !tc.startProtected {
277 r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
278 }
279 // Without an HDRPixels expectation, a readback fails the test.
280 if tc.hdrPixels {
281 r.EXPECT().HDRPixels().RunAndReturn(func() *image.RGBA64 {
282 state.Store(protected)
283 return image.NewRGBA64(image.Rect(0, 0, 2, 2))
284 }).Once()
285 }
286 r.EXPECT().Close().Return().Once()
287 scenes := make(chan ports.Scene, 1)
288 scenes <- ports.Scene{Seq: 1, Security: unprotected, OutputWidth: 2, OutputHeight: 2}
289 ctx, cancel := context.WithCancel(context.Background())
290 done := make(chan error, 1)
291 go func() {
292 done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Name: "H", Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
293 }()
294 // The black protection frame follows the frame under test (or is
295 // the only one): the raw block is over once it is rendered.
296 for s := securityReceive(t, rendered); !s.Security.Protected; s = securityReceive(t, rendered) {
297 }
298 cancel()
299 if err := securityReceive(t, done); err != nil {
300 t.Fatal(err)
301 }
302 if _, err := os.Stat(filepath.Join(dir, "latest.png")); (err == nil) != tc.wantLatest {
303 t.Errorf("latest.png: %v, want present %v", err, tc.wantLatest)
304 }
305 if _, err := os.Stat(filepath.Join(dir, "latest-pq.png")); !os.IsNotExist(err) {
306 t.Errorf("latest-pq.png delivered: %v", err)
307 }
308 })
309 }
310 // The write gate itself refuses a stale or protected 16-bit image.
311 gate := portsmocks.NewMockSessionSecurity(t)
312 gate.EXPECT().Snapshot().Return(protected)
313 path := filepath.Join(t.TempDir(), "latest-pq.png")
314 if err := writePNGSecure(path, image.NewRGBA64(image.Rect(0, 0, 1, 1)), gate, ports.SecurityState{}); err != nil {
315 t.Fatal(err)
316 }
317 if _, err := os.Stat(path); !os.IsNotExist(err) {
318 t.Fatal("stale raw screenshot delivered")
319 }
320}
321
322func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) {
323 var state atomic.Value
324 state.Store(ports.SecurityState{})
325 gate := portsmocks.NewMockSessionSecurity(t)
326 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
327 r := portsmocks.NewMockRenderer(t)
328 r.EXPECT().Close().Return().Once()
329 black := make(chan struct{})
330 r.EXPECT().Render(mock.Anything, mock.Anything).Run(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) { close(black) }).Return(nil, nil).Once()
331 incoming := make(chan ports.CaptureRequest)
332 replies := make(chan ports.CaptureDone, 2)
333 changes := make(chan ports.SecurityState, 1)
334 // No receiver: proof delivery must wait rather than drop, and cancel.
335 proofs := make(chan ports.SecurityBackendEvent)
336 ctx, cancel := context.WithCancel(context.Background())
337 done := make(chan error, 1)
338 go func() {
339 done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 4, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, incoming)
340 }()
341 file, err := os.CreateTemp(t.TempDir(), "queued")
342 if err != nil {
343 t.Fatal(err)
344 }
345 select {
346 case incoming <- ports.CaptureRequest{ID: 100, Dst: ports.SHMBuffer{File: file}}:
347 case <-time.After(time.Second):
348 t.Fatal("capture not admitted")
349 }
350 locked := ports.SecurityState{Generation: 3, Protected: true}
351 state.Store(locked)
352 changes <- locked
353 securityReceive(t, black)
354 cancel()
355 if err := securityReceive(t, done); err != nil {
356 t.Fatal(err)
357 }
358 result := securityReceive(t, replies)
359 if result.ID != 100 || result.Err == nil {
360 t.Fatal(result)
361 }
362 if _, err := file.Stat(); err == nil {
363 t.Fatal("queued FD leaked")
364 }
365 select {
366 case result := <-replies:
367 t.Fatalf("duplicate queued reply: %+v", result)
368 default:
369 }
370}
371
372func TestSecurityOffOutputProofDoesNotRender(t *testing.T) {
373 var state atomic.Value
374 state.Store(ports.SecurityState{})
375 gate := portsmocks.NewMockSessionSecurity(t)
376 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
377 r := portsmocks.NewMockRenderer(t)
378 r.EXPECT().Close().Return().Once()
379 scenes := make(chan ports.Scene, 1)
380 scenes <- ports.Scene{Off: true}
381 sink, presented := presentedSink(t, 1)
382 changes := make(chan ports.SecurityState, 1)
383 proofs := make(chan ports.SecurityBackendEvent, 1)
384 ctx, cancel := context.WithCancel(context.Background())
385 done := make(chan error, 1)
386 go func() {
387 done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 12, Presented: sink, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
388 }()
389 securityReceive(t, presented) // Off has actually been applied by the owner.
390 locked := ports.SecurityState{Generation: 4, Protected: true}
391 state.Store(locked)
392 changes <- locked
393 proof := securityReceive(t, proofs).(ports.SecurityOutputProof)
394 if proof.Proof.Kind != ports.ProtectionInactiveOutput || proof.Proof.Generation != 4 || proof.Proof.Instance != 12 {
395 t.Fatal(proof)
396 }
397 cancel()
398 if err := securityReceive(t, done); err != nil {
399 t.Fatal(err)
400 }
401}
402
403func TestSecurityCompletionLeaseRecycledBeforeProtectionRenderFailure(t *testing.T) {
404 var state atomic.Value
405 state.Store(ports.SecurityState{})
406 gate := portsmocks.NewMockSessionSecurity(t)
407 var submitted atomic.Bool
408 ownerWaiting := make(chan struct{})
409 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
410 v := state.Load().(ports.SecurityState)
411 if submitted.CompareAndSwap(true, false) {
412 close(ownerWaiting)
413 }
414 return v
415 })
416 r := portsmocks.NewMockRenderer(t)
417 frame := portsmocks.NewMockCaptureFrame(t)
418 frame.EXPECT().Done().Return(nil)
419 frame.EXPECT().Read(mock.Anything, mock.Anything, 8).RunAndReturn(func(image.Rectangle, []byte, int) error {
420 <-ownerWaiting // owner has checked the old gate and is selecting
421 state.Store(ports.SecurityState{Generation: 1, Protected: true})
422 return nil
423 }).Once()
424 r.EXPECT().BeginCapture().Run(func() { submitted.Store(true) }).Return(frame, nil).Once()
425 var recycled atomic.Bool
426 r.EXPECT().EndCapture(frame).Run(func(ports.CaptureFrame) { recycled.Store(true) }).Return().Once()
427 failure := errors.New("protection render failed")
428 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
429 if s.Seq == 0 {
430 if !recycled.Load() {
431 t.Error("pulled completion lease not recycled before transition")
432 }
433 return nil, failure
434 }
435 return nil, nil
436 }).Twice()
437 r.EXPECT().Close().Return().Once()
438 incoming := make(chan ports.CaptureRequest)
439 scenes := make(chan ports.Scene)
440 replies := make(chan ports.CaptureDone, 1)
441 ctx, cancel := context.WithCancel(context.Background())
442 defer cancel()
443 done := make(chan error, 1)
444 go func() {
445 done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, incoming)
446 }()
447 file, err := os.CreateTemp(t.TempDir(), "capture")
448 if err != nil {
449 t.Fatal(err)
450 }
451 if err := file.Truncate(16); err != nil {
452 t.Fatal(err)
453 }
454 incoming <- ports.CaptureRequest{ID: 77, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: ports.SHMBuffer{File: file}}
455 scenes <- ports.Scene{Seq: 1, Scale: 1}
456 securityReceive(t, replies)
457 if err := securityReceive(t, done); !errors.Is(err, failure) {
458 t.Fatal(err)
459 }
460 if !recycled.Load() {
461 t.Fatal("capture lease leaked")
462 }
463 // Read arguments contain an mmap slice that capture has now unmapped;
464 // don't let testify format that expired borrowed memory at cleanup.
465 frame.Calls = nil
466}