1package headless
2
3import (
4 "context"
5 "errors"
6 "image"
7 "os"
8 "path/filepath"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
14 "github.com/bnema/neferwl/internal/ports"
15 "github.com/stretchr/testify/mock"
16)
17
18func securityReceive[T any](t *testing.T, ch <-chan T) T {
19 t.Helper()
20 select {
21 case v := <-ch:
22 return v
23 case <-time.After(time.Second):
24 t.Fatal("timed out")
25 var z T
26 return z
27 }
28}
29
30func TestSecurityInitialBlackFenceBeforeLockerAndNoReadback(t *testing.T) {
31 state := ports.SecurityState{Generation: 7, Protected: true}
32 gate := portsmocks.NewMockSessionSecurity(t)
33 gate.EXPECT().Snapshot().Return(state)
34 r := portsmocks.NewMockRenderer(t)
35 r.EXPECT().Close().Return().Once()
36 fence, signal, err := os.Pipe()
37 if err != nil {
38 t.Fatal(err)
39 }
40 defer signal.Close()
41 rendered := make(chan ports.Scene, 4)
42 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
43 if s.Seq == 0 {
44 if s.Background != "#000000" || s.Security != state || len(s.Windows) != 0 || len(s.Layers) != 0 || s.Capture != nil || s.CaptureScene != nil || c != nil {
45 t.Errorf("unsafe initial black: %+v surfaces %v", s, c)
46 }
47 rendered <- s
48 return fence, nil
49 }
50 if s.Security != state || s.Seq != 2 {
51 t.Errorf("stale desktop rendered: %+v", s)
52 }
53 rendered <- s
54 return nil, nil
55 }).Twice()
56 // No Pixels/BeginCapture expectation: either call fails this test.
57 scenes := make(chan ports.Scene, 3)
58 scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}}
59 scenes <- ports.Scene{Security: state, Seq: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 10, Fullscreen: true, Rect: ports.Rect{W: 2, H: 2}}}}
60 contents := make(chan ports.SurfaceContent, 1)
61 acquire, acquireSignal, err := os.Pipe()
62 if err != nil {
63 t.Fatal(err)
64 }
65 defer acquire.Close()
66 defer acquireSignal.Close()
67 contents <- ports.SurfaceContent{ID: 10, SHM: &ports.SHMBuffer{Pool: 10}, Acquire: acquire}
68 proof := make(chan ports.SecurityBackendEvent, 4)
69 replies := make(chan ports.CaptureDone, 4)
70 requests := make(chan ports.CaptureRequest, 2)
71 dst, err := os.CreateTemp(t.TempDir(), "capture")
72 if err != nil {
73 t.Fatal(err)
74 }
75 requests <- ports.CaptureRequest{ID: 44, Dst: ports.SHMBuffer{File: dst}}
76 ctx, cancel := context.WithCancel(context.Background())
77 done := make(chan error, 1)
78 dir := t.TempDir()
79 go func() {
80 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, nil, requests)
81 }()
82 t.Cleanup(func() {
83 cancel()
84 if err := securityReceive(t, done); err != nil {
85 t.Error(err)
86 }
87 })
88 securityReceive(t, rendered)
89 select {
90 case p := <-proof:
91 t.Fatalf("proof before fence: %+v", p)
92 default:
93 }
94 if _, err := signal.Write([]byte{1}); err != nil {
95 t.Fatal(err)
96 }
97 p := securityReceive(t, proof).(ports.SecurityOutputProof)
98 if p.Proof != (ports.OutputProtection{Generation: 7, Instance: 9, Kind: ports.ProtectionProtectedFrame}) {
99 t.Fatal(p)
100 }
101 securityReceive(t, rendered)
102 result := securityReceive(t, replies)
103 if result.ID != 44 || result.Err == nil {
104 t.Fatal(result)
105 }
106 if _, err := dst.Stat(); err == nil {
107 t.Fatal("capture destination not closed")
108 }
109 if err := dst.Close(); err == nil {
110 t.Fatal("destination was still open")
111 }
112 select {
113 case result := <-replies:
114 t.Fatalf("duplicate failure: %+v", result)
115 default:
116 }
117 files, err := os.ReadDir(dir)
118 if err != nil || len(files) != 0 {
119 t.Fatalf("protected screenshot files: %v %v", files, err)
120 }
121}
122
123func TestSecurityChangesWakeIdleAndRejectQueuedDesktop(t *testing.T) {
124 var state atomic.Value
125 state.Store(ports.SecurityState{})
126 gate := portsmocks.NewMockSessionSecurity(t)
127 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
128 r := portsmocks.NewMockRenderer(t)
129 r.EXPECT().Close().Return().Once()
130 rendered := make(chan ports.Scene, 4)
131 desktopFinished := make(chan struct{})
132 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
133 if s.Seq != 0 {
134 rendered <- s
135 <-desktopFinished
136 return nil, nil
137 }
138 if len(s.Windows) != 0 || c != nil || !s.Security.Protected {
139 t.Errorf("bad transition black %+v", s)
140 }
141 rendered <- s
142 return nil, nil
143 }).Twice()
144 scenes := make(chan ports.Scene, 2)
145 scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}}
146 changes := make(chan ports.SecurityState, 1)
147 proofs := make(chan ports.SecurityBackendEvent, 2)
148 presented := make(chan ports.OutputPresented, 4)
149 ctx, cancel := context.WithCancel(context.Background())
150 done := make(chan error, 1)
151 go func() {
152 done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 3, SecurityEvents: proofs, Presented: presented, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
153 }()
154 t.Cleanup(func() {
155 cancel()
156 if err := securityReceive(t, done); err != nil {
157 t.Error(err)
158 }
159 })
160 securityReceive(t, rendered)
161 locked := ports.SecurityState{Generation: 1, Protected: true}
162 state.Store(locked)
163 scenes <- ports.Scene{Seq: 99, Windows: []ports.SceneWindow{{ID: 1}}}
164 changes <- locked
165 close(desktopFinished)
166 s := securityReceive(t, rendered)
167 if s.Security != locked || s.Seq != 0 {
168 t.Fatal(s)
169 }
170 securityReceive(t, proofs)
171 select {
172 case p := <-presented:
173 if p.Flip != nil {
174 t.Fatalf("stale render reported: %+v", p)
175 }
176 default:
177 }
178}
179
180func TestSecurityDedicatedChangeWakesWithoutScene(t *testing.T) {
181 var state atomic.Value
182 state.Store(ports.SecurityState{})
183 gate := portsmocks.NewMockSessionSecurity(t)
184 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
185 r := portsmocks.NewMockRenderer(t)
186 r.EXPECT().Close().Return().Once()
187 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
188 changes := make(chan ports.SecurityState, 1)
189 proof := make(chan ports.SecurityBackendEvent, 1)
190 ctx, cancel := context.WithCancel(context.Background())
191 done := make(chan error, 1)
192 go func() {
193 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)
194 }()
195 locked := ports.SecurityState{Generation: 2, Protected: true}
196 state.Store(locked)
197 changes <- locked
198 securityReceive(t, proof)
199 cancel()
200 if err := securityReceive(t, done); err != nil {
201 t.Fatal(err)
202 }
203}
204
205func TestSecurityCursorAndPNGSuppressed(t *testing.T) {
206 gate := portsmocks.NewMockSessionSecurity(t)
207 state := ports.SecurityState{Generation: 1, Protected: true}
208 gate.EXPECT().Snapshot().Return(state)
209 c := &Cursor{}
210 c.set(ports.CursorImage{W: 1, H: 1, Pixels: []byte{0, 0, 255, 255}})
211 c.Move(0, 0)
212 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
213 c.drawSecure(img, gate)
214 if img.Pix[0] != 0 {
215 t.Fatal("protected cursor drawn")
216 }
217 path := filepath.Join(t.TempDir(), "shot.png")
218 if err := writePNGSecure(path, img, gate, ports.SecurityState{}); err != nil {
219 t.Fatal(err)
220 }
221 if _, err := os.Stat(path); !os.IsNotExist(err) {
222 t.Fatal("stale screenshot delivered")
223 }
224}
225
226func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) {
227 var state atomic.Value
228 state.Store(ports.SecurityState{})
229 gate := portsmocks.NewMockSessionSecurity(t)
230 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
231 r := portsmocks.NewMockRenderer(t)
232 r.EXPECT().Close().Return().Once()
233 black := make(chan struct{})
234 r.EXPECT().Render(mock.Anything, mock.Anything).Run(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) { close(black) }).Return(nil, nil).Once()
235 incoming := make(chan ports.CaptureRequest)
236 replies := make(chan ports.CaptureDone, 2)
237 changes := make(chan ports.SecurityState, 1)
238 // No receiver: proof delivery must wait rather than drop, and cancel.
239 proofs := make(chan ports.SecurityBackendEvent)
240 ctx, cancel := context.WithCancel(context.Background())
241 done := make(chan error, 1)
242 go func() {
243 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, incoming)
244 }()
245 file, err := os.CreateTemp(t.TempDir(), "queued")
246 if err != nil {
247 t.Fatal(err)
248 }
249 select {
250 case incoming <- ports.CaptureRequest{ID: 100, Dst: ports.SHMBuffer{File: file}}:
251 case <-time.After(time.Second):
252 t.Fatal("capture not admitted")
253 }
254 locked := ports.SecurityState{Generation: 3, Protected: true}
255 state.Store(locked)
256 changes <- locked
257 securityReceive(t, black)
258 cancel()
259 if err := securityReceive(t, done); err != nil {
260 t.Fatal(err)
261 }
262 result := securityReceive(t, replies)
263 if result.ID != 100 || result.Err == nil {
264 t.Fatal(result)
265 }
266 if _, err := file.Stat(); err == nil {
267 t.Fatal("queued FD leaked")
268 }
269 select {
270 case result := <-replies:
271 t.Fatalf("duplicate queued reply: %+v", result)
272 default:
273 }
274}
275
276func TestSecurityOffOutputProofDoesNotRender(t *testing.T) {
277 var state atomic.Value
278 state.Store(ports.SecurityState{})
279 gate := portsmocks.NewMockSessionSecurity(t)
280 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
281 r := portsmocks.NewMockRenderer(t)
282 r.EXPECT().Close().Return().Once()
283 scenes := make(chan ports.Scene, 1)
284 scenes <- ports.Scene{Off: true}
285 presented := make(chan ports.OutputPresented, 1)
286 changes := make(chan ports.SecurityState, 1)
287 proofs := make(chan ports.SecurityBackendEvent, 1)
288 ctx, cancel := context.WithCancel(context.Background())
289 done := make(chan error, 1)
290 go func() {
291 done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 12, Presented: presented, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
292 }()
293 securityReceive(t, presented) // Off has actually been applied by the owner.
294 locked := ports.SecurityState{Generation: 4, Protected: true}
295 state.Store(locked)
296 changes <- locked
297 proof := securityReceive(t, proofs).(ports.SecurityOutputProof)
298 if proof.Proof.Kind != ports.ProtectionInactiveOutput || proof.Proof.Generation != 4 || proof.Proof.Instance != 12 {
299 t.Fatal(proof)
300 }
301 cancel()
302 if err := securityReceive(t, done); err != nil {
303 t.Fatal(err)
304 }
305}
306
307func TestSecurityCompletionLeaseRecycledBeforeProtectionRenderFailure(t *testing.T) {
308 var state atomic.Value
309 state.Store(ports.SecurityState{})
310 gate := portsmocks.NewMockSessionSecurity(t)
311 var submitted atomic.Bool
312 ownerWaiting := make(chan struct{})
313 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
314 v := state.Load().(ports.SecurityState)
315 if submitted.CompareAndSwap(true, false) {
316 close(ownerWaiting)
317 }
318 return v
319 })
320 r := portsmocks.NewMockRenderer(t)
321 frame := portsmocks.NewMockCaptureFrame(t)
322 frame.EXPECT().Done().Return(nil)
323 frame.EXPECT().Read(mock.Anything, mock.Anything, 8).RunAndReturn(func(image.Rectangle, []byte, int) error {
324 <-ownerWaiting // owner has checked the old gate and is selecting
325 state.Store(ports.SecurityState{Generation: 1, Protected: true})
326 return nil
327 }).Once()
328 r.EXPECT().BeginCapture().Run(func() { submitted.Store(true) }).Return(frame, nil).Once()
329 var recycled atomic.Bool
330 r.EXPECT().EndCapture(frame).Run(func(ports.CaptureFrame) { recycled.Store(true) }).Return().Once()
331 failure := errors.New("protection render failed")
332 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
333 if s.Seq == 0 {
334 if !recycled.Load() {
335 t.Error("pulled completion lease not recycled before transition")
336 }
337 return nil, failure
338 }
339 return nil, nil
340 }).Twice()
341 r.EXPECT().Close().Return().Once()
342 incoming := make(chan ports.CaptureRequest)
343 scenes := make(chan ports.Scene)
344 replies := make(chan ports.CaptureDone, 1)
345 ctx, cancel := context.WithCancel(context.Background())
346 defer cancel()
347 done := make(chan error, 1)
348 go func() {
349 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, incoming)
350 }()
351 file, err := os.CreateTemp(t.TempDir(), "capture")
352 if err != nil {
353 t.Fatal(err)
354 }
355 if err := file.Truncate(16); err != nil {
356 t.Fatal(err)
357 }
358 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}}
359 scenes <- ports.Scene{Seq: 1, Scale: 1}
360 securityReceive(t, replies)
361 if err := securityReceive(t, done); !errors.Is(err, failure) {
362 t.Fatal(err)
363 }
364 if !recycled.Load() {
365 t.Fatal("capture lease leaked")
366 }
367 // Read arguments contain an mmap slice that capture has now unmapped;
368 // don't let testify format that expired borrowed memory at cleanup.
369 frame.Calls = nil
370}