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	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}

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