feat/data-dnd e05cb15

security_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close