security_test.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"errors"
6	"os"
7	"testing"
8	"time"
9
10	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11	"github.com/bnema/neferwl/internal/ports"
12)
13
14func TestSecurityRegistrationBeforeStart(t *testing.T) {
15	ctx, cancel := context.WithCancel(context.Background())
16	defer cancel()
17	events := make(chan ports.SecurityBackendEvent)
18	set := newOutputSet(ctx, nil)
19	set.wireSecurity(outputChannels{securityEvents: events})
20	set.setSecurity(ports.SecurityState{Generation: 7, Protected: true})
21	started := make(chan ports.OutputInstance, 1)
22	result := make(chan bool, 1)
23	go func() {
24		result <- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {
25			state := <-security
26			if state != (ports.SecurityState{Generation: 7, Protected: true}) {
27				return errors.New("missing startup protection")
28			}
29			started <- instance
30			<-ctx.Done()
31			return nil
32		})
33	}()
34	select {
35	case <-started:
36		t.Fatal("owner ran before registration")
37	default:
38	}
39	var added ports.SecurityOutputAdded
40	select {
41	case event := <-events:
42		added = event.(ports.SecurityOutputAdded)
43	case <-time.After(2 * time.Second):
44		t.Fatal("registration missing")
45	}
46	if added.Instance == 0 || added.Output != "DP-1" {
47		t.Fatalf("registration: %+v", added)
48	}
49	if !<-result {
50		t.Fatal("start failed")
51	}
52	select {
53	case instance := <-started:
54		if instance != added.Instance {
55			t.Fatal("owner lifetime differs")
56		}
57	case <-time.After(2 * time.Second):
58		t.Fatal("owner missing startup protection")
59	}
60	if err := set.wait(); err != nil {
61		t.Fatal(err)
62	}
63}
64
65func TestSecurityReplacementAndStaleState(t *testing.T) {
66	ctx := context.Background()
67	events := make(chan ports.SecurityBackendEvent, 4)
68	set := newOutputSet(ctx, nil)
69	set.wireSecurity(outputChannels{securityEvents: events})
70	run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
71		<-ctx.Done()
72		return nil
73	}
74	set.start(ctx, "DP-1", run)
75	old := (<-events).(ports.SecurityOutputAdded)
76	set.outs["DP-1"].stop()
77	if err := set.finish("DP-1"); err != nil {
78		t.Fatal(err)
79	}
80	set.start(ctx, "DP-1", run)
81	replacement := (<-events).(ports.SecurityOutputAdded)
82	if replacement.Instance <= old.Instance {
83		t.Fatal("reused lifetime")
84	}
85	if set.stoppedCurrent(outputStopped{name: "DP-1", instance: old.Instance}) {
86		t.Fatal("old stop matches replacement")
87	}
88	set.setSecurity(ports.SecurityState{Generation: 3, Protected: true})
89	set.setSecurity(ports.SecurityState{Generation: 2})
90	if state := <-set.outs["DP-1"].security; state.Generation != 3 || !state.Protected {
91		t.Fatalf("stale transition: %+v", state)
92	}
93	set.setSecurity(ports.SecurityState{Generation: 4})
94	if state := <-set.outs["DP-1"].security; state.Generation != 4 || state.Protected {
95		t.Fatalf("unlock not forwarded: %+v", state)
96	}
97	if err := set.wait(); err != nil {
98		t.Fatal(err)
99	}
100	select {
101	case ev := <-events:
102		t.Fatalf("stop emitted false removal: %+v", ev)
103	default:
104	}
105}
106
107func TestGuardedLeaseRejectsProtected(t *testing.T) {
108	gate := portsmocks.NewMockSessionSecurity(t)
109	gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}).Once()
110	card := newMocksecurityLeaseCard(t)
111	fd, id, err := guardedLease(card, gate, []string{"DP-1"})
112	if fd != nil || id != 0 || err == nil {
113		t.Fatalf("protected lease: %v %d %v", fd, id, err)
114	}
115}
116
117func TestGuardedLeaseCrossingTransition(t *testing.T) {
118	for _, revokeErr := range []error{nil, errors.New("revoke failed")} {
119		t.Run(map[bool]string{false: "revoked", true: "failed"}[revokeErr != nil], func(t *testing.T) {
120			fd, err := os.CreateTemp(t.TempDir(), "lease")
121			if err != nil {
122				t.Fatal(err)
123			}
124			gate := portsmocks.NewMockSessionSecurity(t)
125			gate.EXPECT().Snapshot().Return(ports.SecurityState{}).Once()
126			gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}).Once()
127			card := newMocksecurityLeaseCard(t)
128			card.EXPECT().Lease([]string{"DP-1"}).Return(fd, uint32(12), nil).Once()
129			card.EXPECT().Revoke(uint32(12)).Return(revokeErr).Once()
130			got, id, err := guardedLease(card, gate, []string{"DP-1"})
131			if got != nil || id != 0 || err == nil {
132				t.Fatalf("late grant escaped: %v %d %v", got, id, err)
133			}
134			if _, statErr := fd.Stat(); !errors.Is(statErr, os.ErrClosed) {
135				t.Fatalf("FD not closed: %v", statErr)
136			}
137			if revokeErr != nil && !errors.Is(err, revokeErr) {
138				t.Fatal("revoke failure lost")
139			}
140		})
141	}
142}
143
144func TestSecurityRevokesAllLeasesAndRetainsFailures(t *testing.T) {
145	card := newMocksecurityLeaseCard(t)
146	card.EXPECT().LeaseIDs().Return([]uint32{1, 2}).Once()
147	card.EXPECT().LeaseIDs().Return([]uint32{2}).Once()
148	card.EXPECT().Revoke(uint32(1)).Return(nil).Once()
149	failed := errors.New("native revoke failure")
150	card.EXPECT().Revoke(uint32(2)).Return(failed).Once()
151	card.EXPECT().Path().Return("card0")
152	var finished []uint32
153	err := revokeSecurityLeases(card, func(id uint32) { finished = append(finished, id) })
154	if !errors.Is(err, failed) || len(finished) != 1 || finished[0] != 1 {
155		t.Fatalf("revocation: %v finished %v", err, finished)
156	}
157	card.EXPECT().LeaseIDs().Return([]uint32{2}).Once()
158	card.EXPECT().LeaseIDs().Return([]uint32{}).Once()
159	card.EXPECT().Revoke(uint32(2)).Return(nil).Once()
160	if err := revokeSecurityLeases(card, func(id uint32) {}); err != nil {
161		t.Fatal(err)
162	}
163}
164
165func TestSecurityInstanceExhaustion(t *testing.T) {
166	set := newOutputSet(context.Background(), nil)
167	events := make(chan ports.SecurityBackendEvent, 2)
168	set.wireSecurity(outputChannels{securityEvents: events})
169	set.nextInstance = ^ports.OutputInstance(0) - 1
170	run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
171		<-ctx.Done()
172		return nil
173	}
174	if !set.start(context.Background(), "last", run) {
175		t.Fatal("last valid lifetime refused")
176	}
177	if added := (<-events).(ports.SecurityOutputAdded); added.Instance != ^ports.OutputInstance(0) {
178		t.Fatalf("last instance: %+v", added)
179	}
180	for range 2 {
181		if set.start(context.Background(), "overflow", run) {
182			t.Fatal("exhausted counter launched owner")
183		}
184	}
185	if set.nextInstance != ^ports.OutputInstance(0) || set.startErr == nil {
186		t.Fatal("counter wrapped or error missing")
187	}
188	select {
189	case ev := <-events:
190		t.Fatalf("registered overflow: %+v", ev)
191	default:
192	}
193	if err := set.wait(); err == nil {
194		t.Fatal("exhaustion not returned")
195	}
196}
197
198// The shared display event channel and an output content queue can both be
199// saturated. Routing must retain the next content without pinning the backend
200// outside its select; a lease barrier can then finish before either is drained.
201func TestSecurityFullEventsAndContentsAllowLeaseBarrier(t *testing.T) {
202	ctx, cancel := context.WithCancel(context.Background())
203	defer cancel()
204	set := newOutputSet(ctx, nil)
205	events := make(chan ports.SecurityBackendEvent, 1)
206	set.wireSecurity(outputChannels{securityEvents: events})
207	var ownerEvents chan<- ports.SecurityBackendEvent
208	set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
209		<-ctx.Done()
210		return nil
211	}, &ownerEvents)
212	<-events                                               // ordered registration
213	events <- ports.SecurityBackendBarrier{Generation: 99} // blocked display
214	r := set.outs["DP-1"]
215	for i := range cap(r.contents) {
216		r.contents <- ports.SurfaceContent{ID: ports.WindowID(i + 1)}
217	}
218	first := ports.SecurityOutputInvalidated{Generation: 1, Instance: r.instance}
219	second := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: 1, Instance: r.instance, Kind: ports.ProtectionProtectedFrame}}
220	ownerEvents <- first
221	ownerEvents <- second
222	card := newMocksecurityLeaseCard(t)
223	card.EXPECT().LeaseIDs().Return([]uint32{9}).Once()
224	card.EXPECT().LeaseIDs().Return([]uint32{}).Once()
225	card.EXPECT().Revoke(uint32(9)).Return(nil).Once()
226	progressed := make(chan error, 1)
227	go func() {
228		set.content(ports.SurfaceContent{ID: 70})
229		set.setSecurity(ports.SecurityState{Generation: 1, Protected: true})
230		err := revokeSecurityLeases(card, func(uint32) {})
231		set.securityEvent(ports.SecurityBackendBarrier{Generation: 1, Err: err})
232		progressed <- err
233	}()
234	select {
235	case err := <-progressed:
236		if err != nil {
237			t.Fatal(err)
238		}
239	case <-time.After(time.Second):
240		t.Fatal("content/event backpressure blocked lease transition")
241	}
242	incoming := make(chan ports.SurfaceContent)
243	contentIn, output, next, owner := set.contentOut(incoming)
244	if contentIn != nil || output != r.contents || owner != r || next.ID != 70 || len(r.pending) != 1 {
245		t.Fatal("pending content not bounded/preserved")
246	}
247	for range cap(r.contents) {
248		<-r.contents
249	}
250	output <- next
251	owner.contentSent()
252	if got := <-r.contents; got.ID != 70 {
253		t.Fatal("lost pending content")
254	}
255	if contentIn, _, _, _ := set.contentOut(incoming); contentIn != incoming {
256		t.Fatal("content intake stayed disabled")
257	}
258	<-events // unblock display
259	var ownerSequence []ports.SecurityBackendEvent
260	for range 3 {
261		select {
262		case ev := <-events:
263			switch ev.(type) {
264			case ports.SecurityOutputInvalidated, ports.SecurityOutputProof:
265				ownerSequence = append(ownerSequence, ev)
266			}
267		case <-time.After(time.Second):
268			t.Fatal("lossless forwarder lost event")
269		}
270	}
271	if len(ownerSequence) != 2 || ownerSequence[0] != first || ownerSequence[1] != second {
272		t.Fatalf("owner order lost: %+v", ownerSequence)
273	}
274	if err := set.wait(); err != nil {
275		t.Fatal(err)
276	}
277}
278
279func TestSecurityLargeReplayDoesNotBlockStart(t *testing.T) {
280	set := newOutputSet(context.Background(), nil)
281	for i := range 100 {
282		set.content(ports.SurfaceContent{ID: ports.WindowID(i + 1), SHM: &ports.SHMBuffer{}})
283	}
284	if !set.start(context.Background(), "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
285		<-ctx.Done()
286		return nil
287	}) {
288		t.Fatal("start failed")
289	}
290	r := set.outs["A"]
291	if len(r.contents) != cap(r.contents) || len(r.pending) != 100-cap(r.contents) {
292		t.Fatal("replay not preserved")
293	}
294	seen := map[ports.WindowID]bool{}
295	for len(seen) != 100 {
296		select {
297		case c := <-r.contents:
298			if seen[c.ID] {
299				t.Fatal("duplicate replay")
300			}
301			seen[c.ID] = true
302		default:
303			_, out, c, owner := set.contentOut(nil)
304			if out == nil {
305				t.Fatal("replay lost")
306			}
307			out <- c
308			owner.contentSent()
309		}
310	}
311	if len(r.pending) != 0 {
312		t.Fatal("replay remains")
313	}
314	if err := set.wait(); err != nil {
315		t.Fatal(err)
316	}
317}

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