main f2d166b

security_hotplug_test.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"reflect"
6	"testing"
7	"testing/synctest"
8	"time"
9
10	"github.com/bnema/neferwl/internal/adapters/wayland/sessionlock"
11	"github.com/bnema/neferwl/internal/ports"
12)
13
14func hotplugEvent(t *testing.T, events <-chan ports.SecurityBackendEvent) ports.SecurityBackendEvent {
15	t.Helper()
16	select {
17	case event := <-events:
18		return event
19	case <-time.After(2 * time.Second):
20		t.Fatal("missing ordered security event")
21		return nil
22	}
23}
24
25// This switch represents the single display owner's ledger consumption, not
26// an alternate backend implementation. Events come through real outputSet FIFO.
27func hotplugApply(ledger *sessionlock.Readiness, event ports.SecurityBackendEvent) bool {
28	switch v := event.(type) {
29	case ports.SecurityOutputAdded:
30		return ledger.Add(v.Instance)
31	case ports.SecurityOutputRemoved:
32		return ledger.Remove(v.Instance)
33	case ports.SecurityOutputInvalidated:
34		return ledger.Invalidate(v.Generation, v.Instance)
35	case ports.SecurityOutputProof:
36		return ledger.Record(v.Proof)
37	case ports.SecurityBackendBarrier:
38		return v.Err == nil && ledger.BackendBarrier(v.Generation)
39	default:
40		return false
41	}
42}
43
44func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) {
45	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
46	defer cancel()
47	events := make(chan ports.SecurityBackendEvent, 8)
48	set := newOutputSet(ctx, nil, outputRoutes{})
49	set.wireSecurity(outputChannels{securityEvents: events})
50	defer func() {
51		if err := set.wait(); err != nil {
52			t.Error(err)
53		}
54	}()
55	const generation = ports.LockGeneration(41)
56	set.setSecurity(ports.SecurityState{Generation: generation, Protected: true})
57	var ledger sessionlock.Readiness
58	if !ledger.Begin(generation) || !ledger.BackendBarrier(generation) || !ledger.CaptureBarrier(generation) {
59		t.Fatal("could not begin display readiness round")
60	}
61	// Native Close success is independently tested in DRM. Here the real
62	// owner callback synchronizes that affirmative result before the backend
63	// sends removal; mere callback exit never emits removal from outputSet.
64	start := func(closeSuccess bool) (ports.OutputInstance, chan []ports.SecurityBackendEvent, <-chan bool) {
65		t.Helper()
66		commands := make(chan []ports.SecurityBackendEvent, 1)
67		closed := make(chan bool, 1)
68		var ownerEvents chan<- ports.SecurityBackendEvent
69		if !set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
70			security, instance := feed.Security, feed.Instance
71			defer func() { closed <- closeSuccess }()
72			state := <-security
73			proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}
74			select {
75			case ownerEvents <- proof:
76			case <-octx.Done():
77				return nil
78			}
79			for {
80				select {
81				case batch := <-commands:
82					for _, event := range batch {
83						select {
84						case ownerEvents <- event:
85						case <-octx.Done():
86							return nil
87						}
88					}
89				case <-octx.Done():
90					return nil
91				}
92			}
93		}, &ownerEvents) {
94			t.Fatal("start refused")
95		}
96		registered := hotplugEvent(t, events)
97		added, ok := registered.(ports.SecurityOutputAdded)
98		if !ok || added.Output != "DP-1" || added.Instance == 0 || !hotplugApply(&ledger, registered) {
99			t.Fatalf("owner evidence preceded lifetime registration: %+v", registered)
100		}
101		if ledger.Ready() {
102			t.Fatal("new lifetime reused previous physical readiness")
103		}
104		proof := hotplugEvent(t, events)
105		if !hotplugApply(&ledger, proof) || !ledger.Ready() {
106			t.Fatalf("missing exact lifetime proof: %+v", proof)
107		}
108		return added.Instance, commands, closed
109	}
110	old, commands, closed := start(true)
111	// A proof already queued by this owner precedes invalidation, even if the
112	// display reads later. No separate forwarder can replay it after revoke.
113	oldProof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: old, Kind: ports.ProtectionProtectedFrame}}
114	invalid := ports.SecurityOutputInvalidated{Generation: generation, Instance: old}
115	commands <- []ports.SecurityBackendEvent{oldProof, invalid}
116	if got := hotplugEvent(t, events); got != oldProof || !hotplugApply(&ledger, got) {
117		t.Fatalf("queued proof lost: %+v", got)
118	}
119	if got := hotplugEvent(t, events); got != invalid || !hotplugApply(&ledger, got) || ledger.Ready() {
120		t.Fatalf("invalidation reordered or ineffective: %+v", got)
121	}
122	commands <- []ports.SecurityBackendEvent{oldProof}
123	if got := hotplugEvent(t, events); got != oldProof || !hotplugApply(&ledger, got) || !ledger.Ready() {
124		t.Fatal("fresh resume proof did not restore readiness")
125	}
126	set.outs["DP-1"].stop()
127	select {
128	case stopped := <-set.stopped:
129		if stopped.instance != old || !set.stoppedCurrent(stopped) {
130			t.Fatalf("wrong stopped lifetime: %+v", stopped)
131		}
132	case <-ctx.Done():
133		t.Fatal("owner did not stop")
134	}
135	if err := set.finish("DP-1"); err != nil {
136		t.Fatal(err)
137	}
138	if !<-closed {
139		t.Fatal("shutdown not affirmative")
140	}
141	select {
142	case got := <-events:
143		t.Fatalf("owner exit forged removal: %+v", got)
144	default:
145	}
146	if !set.securityEvent(ports.SecurityOutputRemoved{Instance: old}) {
147		t.Fatal("confirmed removal refused")
148	}
149	removal := hotplugEvent(t, events)
150	if removal != (ports.SecurityOutputRemoved{Instance: old}) || !hotplugApply(&ledger, removal) {
151		t.Fatalf("wrong removal: %+v", removal)
152	}
153	replacement, _, replacementClosed := start(false)
154	if replacement <= old || set.stoppedCurrent(outputStopped{name: "DP-1", instance: old}) {
155		t.Fatal("same-name replacement reused retired lifetime")
156	}
157	if hotplugApply(&ledger, oldProof) || hotplugApply(&ledger, invalid) {
158		t.Fatal("retired lifetime affected replacement")
159	}
160	stale := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation - 1, Instance: replacement, Kind: ports.ProtectionInactiveOutput}}
161	if hotplugApply(&ledger, stale) || !ledger.Ready() {
162		t.Fatal("stale generation overwrote valid replacement proof")
163	}
164	if !ledger.Invalidate(generation, replacement) || ledger.Ready() {
165		t.Fatal("replacement invalidation ineffective")
166	}
167	set.outs["DP-1"].stop()
168	select {
169	case <-set.stopped:
170	case <-ctx.Done():
171		t.Fatal("replacement stop blocked")
172	}
173	if err := set.finish("DP-1"); err != nil {
174		t.Fatal(err)
175	}
176	if <-replacementClosed {
177		t.Fatal("expected failed shutdown")
178	}
179	select {
180	case got := <-events:
181		t.Fatalf("failed shutdown forged removal: %+v", got)
182	default:
183	}
184	if ledger.Ready() {
185		t.Fatal("failed replacement exit erased required unproved lifetime")
186	}
187}
188
189func TestSecurityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) {
190	synctest.Test(t, securityOwnerFIFOSaturationDrainsOnNormalStop)
191}
192
193func securityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) {
194	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
195	defer cancel()
196	events := make(chan ports.SecurityBackendEvent) // block display after registration
197	set := newOutputSet(ctx, nil, outputRoutes{})
198	set.wireSecurity(outputChannels{securityEvents: events})
199	defer func() {
200		if err := set.wait(); err != nil {
201			t.Error(err)
202		}
203	}()
204	const generation = ports.LockGeneration(7)
205	accepted := make(chan int, 40)
206	startDone := make(chan bool, 1)
207	var ownerEvents chan<- ports.SecurityBackendEvent
208	go func() {
209		startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
210			instance := feed.Instance
211			for i := range 40 {
212				var event ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: instance}
213				if i%2 == 0 {
214					event = ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}
215				}
216				select {
217				case ownerEvents <- event:
218					accepted <- i
219				case <-octx.Done():
220					return nil
221				}
222			}
223			return nil
224		}, &ownerEvents)
225	}()
226	registered := hotplugEvent(t, events).(ports.SecurityOutputAdded)
227	if !<-startDone {
228		t.Fatal("start failed")
229	}
230	if cap(set.outs["DP-1"].securityEvents) != 16 {
231		t.Fatal("owner evidence FIFO is not bounded to 16")
232	}
233	// Forwarder holds one event plus its 16-slot input. The eighteenth send
234	// must remain blocked until the display starts draining.
235	for i := range 17 {
236		select {
237		case got := <-accepted:
238			if got != i {
239				t.Fatalf("acceptance order %d want %d", got, i)
240			}
241		case <-ctx.Done():
242			t.Fatal("FIFO failed to fill")
243		}
244	}
245	synctest.Wait()
246	select {
247	case got := <-accepted:
248		t.Fatalf("full FIFO did not backpressure owner: %d", got)
249	default:
250	}
251	var ledger sessionlock.Readiness
252	ledger.Add(registered.Instance)
253	ledger.Begin(generation)
254	ledger.BackendBarrier(generation)
255	ledger.CaptureBarrier(generation)
256	for i := range 40 {
257		got := hotplugEvent(t, events)
258		var want ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: registered.Instance}
259		if i%2 == 0 {
260			want = ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: registered.Instance, Kind: ports.ProtectionProtectedFrame}}
261		}
262		if !reflect.DeepEqual(got, want) || !hotplugApply(&ledger, got) || ledger.Ready() != (i%2 == 0) {
263			t.Fatalf("evidence lost/duplicated/reordered at %d: %+v", i, got)
264		}
265	}
266	select {
267	case stopped := <-set.stopped:
268		if !set.stoppedCurrent(stopped) {
269			t.Fatal("stop not current")
270		}
271	case <-ctx.Done():
272		t.Fatal("normal owner stop blocked")
273	}
274	if err := set.finish("DP-1"); err != nil {
275		t.Fatal(err)
276	}
277	select {
278	case got := <-events:
279		t.Fatalf("duplicate evidence or removal after normal drain: %+v", got)
280	default:
281	}
282}
283
284// All producer evidence is already buffered when the owner stops normally.
285// finish must not cancel that owner's forwarder or lose its outstanding FIFO.
286func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) {
287	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
288	defer cancel()
289	events := make(chan ports.SecurityBackendEvent)
290	set := newOutputSet(ctx, nil, outputRoutes{})
291	set.wireSecurity(outputChannels{securityEvents: events})
292	defer func() {
293		if err := set.wait(); err != nil {
294			t.Error(err)
295		}
296	}()
297	startDone := make(chan bool, 1)
298	var ownerEvents chan<- ports.SecurityBackendEvent
299	go func() {
300		startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
301			instance := feed.Instance
302			for i := range 16 {
303				event := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: instance}
304				select {
305				case ownerEvents <- event:
306				case <-octx.Done():
307					return nil
308				}
309			}
310			return nil
311		}, &ownerEvents)
312	}()
313	registered := hotplugEvent(t, events).(ports.SecurityOutputAdded)
314	if !<-startDone {
315		t.Fatal("start refused")
316	}
317	select {
318	case stopped := <-set.stopped:
319		if stopped.instance != registered.Instance {
320			t.Fatal("wrong stopped lifetime")
321		}
322	case <-ctx.Done():
323		t.Fatal("buffered owner did not finish")
324	}
325	if err := set.finish("DP-1"); err != nil {
326		t.Fatal(err)
327	}
328	for i := range 16 {
329		want := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: registered.Instance}
330		if got := hotplugEvent(t, events); got != want {
331			t.Fatalf("buffered stopped-owner evidence %d: %+v", i, got)
332		}
333	}
334	select {
335	case got := <-events:
336		t.Fatalf("duplicate buffered evidence: %+v", got)
337	default:
338	}
339}
340
341func TestSecurityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) {
342	synctest.Test(t, securityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders)
343}
344
345func securityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) {
346	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
347	defer cancel()
348	events := make(chan ports.SecurityBackendEvent)
349	set := newOutputSet(ctx, nil, outputRoutes{})
350	set.wireSecurity(outputChannels{securityEvents: events})
351	accepted := make(chan int, 40)
352	ownerDone := make(chan struct{})
353	startDone := make(chan bool, 1)
354	var ownerEvents chan<- ports.SecurityBackendEvent
355	go func() {
356		startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
357			instance := feed.Instance
358			defer close(ownerDone)
359			for i := range 40 {
360				select {
361				case ownerEvents <- ports.SecurityOutputInvalidated{Generation: 7, Instance: instance}:
362					accepted <- i
363				case <-octx.Done():
364					return nil
365				}
366			}
367			return nil
368		}, &ownerEvents)
369	}()
370	hotplugEvent(t, events) // registered before producer starts
371	if !<-startDone {
372		t.Fatal("start failed")
373	}
374	for range 17 {
375		select {
376		case <-accepted:
377		case <-ctx.Done():
378			t.Fatal("FIFO never filled")
379		}
380	}
381	synctest.Wait()
382	select {
383	case <-accepted:
384		t.Fatal("producer did not block at finite FIFO")
385	default:
386	}
387	waitDone := make(chan error, 1)
388	go func() { waitDone <- set.wait() }()
389	select {
390	case err := <-waitDone:
391		if err != nil {
392			t.Fatal(err)
393		}
394	case <-ctx.Done():
395		t.Fatal("saturated evidence leaked owner/forwarder on cancellation")
396	}
397	select {
398	case <-ownerDone:
399	default:
400		t.Fatal("wait returned before producer exited")
401	}
402	if len(set.outs) != 0 {
403		t.Fatal("stopped lifetime retained in running set")
404	}
405	// wait includes forwarders.Wait: termination is directly synchronized,
406	// rather than guessed from goroutine counts or arbitrary sleeps.
407}

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 26

Tags 10

↑↓ moveenter openesc close