v0.4.0 b6be3a8

security_capture_test.go RawHistoryFolder

1package headless
2
3import (
4	"context"
5	"errors"
6	"image"
7	"os"
8	"sync/atomic"
9	"testing"
10
11	"github.com/bnema/neferwl/internal/adapters/capture"
12	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13	"github.com/bnema/neferwl/internal/ports"
14	"github.com/stretchr/testify/mock"
15)
16
17// Change the gate AFTER the output's post-Render check returns the admitting
18// state. SubmitScoped must close this last gap before native BeginCapture.
19func TestScopedCaptureClosesPostRenderAdmissionGap(t *testing.T) {
20	for _, excluded := range []bool{false, true} {
21		name := "normal"
22		if excluded {
23			name = "excluded"
24		}
25		t.Run(name, func(t *testing.T) {
26			admitted := ports.SecurityState{Generation: 2}
27			protected := ports.SecurityState{Generation: 3, Protected: true}
28			var state atomic.Value
29			state.Store(admitted)
30			var arm atomic.Bool
31			gate := portsmocks.NewMockSessionSecurity(t)
32			gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
33				before := state.Load().(ports.SecurityState)
34				if arm.CompareAndSwap(true, false) {
35					state.Store(protected)
36				}
37				return before
38			})
39			r := portsmocks.NewMockRenderer(t)
40			r.EXPECT().Close().Return().Once()
41			frameRendered := make(chan struct{}, 1)
42			proof := make(chan ports.SecurityBackendEvent, 1)
43			r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
44				if s.Security == protected {
45					if s.Seq != 0 || len(s.Windows) != 0 {
46						t.Errorf("bad protection frame %+v", s)
47					}
48					return nil, nil
49				}
50				if s.Security != admitted {
51					t.Errorf("wrong render epoch %+v", s)
52				}
53				arm.Store(true)
54				frameRendered <- struct{}{}
55				return nil, nil
56			}).Twice()
57			// BeginCapture has no expectation: any native export fails the test.
58			ctx, cancel := context.WithCancel(context.Background())
59			done := make(chan error, 1)
60			scenes := make(chan ports.Scene, 1)
61			incoming := make(chan ports.CaptureRequest)
62			replies := make(chan ports.CaptureDone, 2)
63			go func() {
64				done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming)
65			}()
66			dst := sessionCaptureFile(t)
67			incoming <- ports.CaptureRequest{ID: 91, Exclude: excluded, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst}
68			scene := ports.Scene{Security: admitted, Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2}
69			if excluded {
70				scene = sessionScene()
71				scene.Security = admitted
72				scene.Scale = 1
73			}
74			scenes <- scene
75			securityReceive(t, frameRendered)
76			result := securityReceive(t, replies)
77			if result.ID != 91 || !errors.Is(result.Err, capture.ErrSecurityState) {
78				t.Fatalf("scoped rejection missing: %+v", result)
79			}
80			if _, err := dst.File.Stat(); !errors.Is(err, os.ErrClosed) {
81				t.Fatalf("destination not closed: %v", err)
82			}
83			securityReceive(t, proof)
84			cancel()
85			if err := securityReceive(t, done); err != nil {
86				t.Fatal(err)
87			}
88			select {
89			case extra := <-replies:
90				t.Fatalf("duplicate reply %+v", extra)
91			default:
92			}
93		})
94	}
95}
96
97func TestTransitionDuringHeadlessRenderWaitRetainsFenceAndRejectsCapture(t *testing.T) {
98	admitted := ports.SecurityState{Generation: 2}
99	locked := ports.SecurityState{Generation: 3, Protected: true}
100	var epoch atomic.Value
101	epoch.Store(admitted)
102	gate := portsmocks.NewMockSessionSecurity(t)
103	gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
104	fence, signal, err := os.Pipe()
105	if err != nil {
106		t.Fatal(err)
107	}
108	defer signal.Close()
109	r := portsmocks.NewMockRenderer(t)
110	r.EXPECT().Close().Return().Once()
111	rendered := make(chan struct{}, 1)
112	r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
113		if s.Security == admitted {
114			epoch.Store(locked)
115			rendered <- struct{}{}
116			return fence, nil
117		}
118		return nil, nil
119	}).Twice()
120	scenes := make(chan ports.Scene, 1)
121	incoming := make(chan ports.CaptureRequest)
122	replies := make(chan ports.CaptureDone, 2)
123	proof := make(chan ports.SecurityBackendEvent, 1)
124	ctx, cancel := context.WithCancel(context.Background())
125	done := make(chan error, 1)
126	go func() {
127		done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming)
128	}()
129	dst := sessionCaptureFile(t)
130	incoming <- ports.CaptureRequest{ID: 92, Dst: dst}
131	scenes <- ports.Scene{Security: admitted, Seq: 2}
132	securityReceive(t, rendered)
133	if _, err := fence.Stat(); err != nil {
134		t.Fatal("pending renderer read fence closed early")
135	}
136	select {
137	case p := <-proof:
138		t.Fatalf("proof on stalled old render %+v", p)
139	default:
140	}
141	if _, err := signal.Write([]byte{1}); err != nil {
142		t.Fatal(err)
143	}
144	result := securityReceive(t, replies)
145	if result.ID != 92 || result.Err == nil {
146		t.Fatal(result)
147	}
148	securityReceive(t, proof)
149	cancel()
150	if err := securityReceive(t, done); err != nil {
151		t.Fatal(err)
152	}
153	if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) {
154		t.Fatal("completed render fence leaked")
155	}
156	if _, err := dst.File.Stat(); !errors.Is(err, os.ErrClosed) {
157		t.Fatal("rejected capture FD leaked")
158	}
159	select {
160	case extra := <-replies:
161		t.Fatalf("duplicate reply %+v", extra)
162	default:
163	}
164}

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