feat/hints a471fa4

security_test.go RawHistoryFolder

1package core_test
2
3import (
4	"context"
5	"sync/atomic"
6	"testing"
7
8	"github.com/bnema/neferwl/internal/core"
9	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10	"github.com/bnema/neferwl/internal/ports"
11)
12
13func TestSecurityOwnerAdversarialQueues(t *testing.T) {
14	var epoch atomic.Value
15	epoch.Store(ports.SecurityState{})
16	gate := portsmocks.NewMockSessionSecurity(t)
17	gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
18	client := make(chan ports.ClientEvent, 32)
19	input := make(chan ports.InputEvent, 32)
20	output := make(chan ports.OutputEvent, 8)
21	reload := make(chan ports.ConfigChanged, 8)
22	commands := make(chan ports.ClientCommand, 256)
23	scenes := make(chan []ports.Scene, 1)
24	spawn := make(chan ports.SpawnRequest, 8)
25	constraints := make(chan ports.PointerConstraint, 1)
26	cfg := scrollDefaults()
27	cfg.Terminal.Command = []string{"terminal"}
28	cfg.Terminal.AutoOpen = "off"
29	cfg.Binds = map[string]string{"Super+q": "quit"}
30	c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes, Spawn: spawn, Constraints: constraints}, core.Options{Security: gate, Terminal: true})
31	if err != nil {
32		t.Fatal(err)
33	}
34	ctx, cancel := context.WithCancel(context.Background())
35	done := make(chan error, 1)
36	go func() { done <- c.Run(ctx) }()
37	t.Cleanup(func() {
38		cancel()
39		if err := receive(t, done); err != nil {
40			t.Error(err)
41		}
42	})
43	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 300, Height: 200}}
44	scene(t, scenes)
45	client <- ports.WindowMapped{ID: 1}
46	scene(t, scenes)
47	// A pre-lock key is queued BEFORE any SessionLockChanged event. The gate
48	// must block it regardless of which channel select chooses next.
49	locked := ports.SecurityState{Generation: 1, Protected: true}
50	epoch.Store(locked)
51	input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}}
52	s := sceneMatch(t, scenes, func(s ports.Scene) bool { return s.Security == locked })
53	assertProtected(t, s)
54	if len(s.Windows) != 0 {
55		t.Fatal(s)
56	}
57	receive(t, constraints)
58	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 200, Height: 100}}
59	sceneMatch(t, scenes, func(s ports.Scene) bool { return s.Security == locked })
60	surfaces := []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 300, Height: 200}, {ID: 20, Output: "B", Width: 200, Height: 100}}
61	client <- ports.SessionLockChanged{State: locked, Surfaces: surfaces}
62	s = sceneMatch(t, scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 && s.Windows[0].ID == 10 })
63	assertProtected(t, s)
64	// Raw input is refused even in the initial unlocked epoch when wired;
65	// stale, raw, bind and swipe events must not activate desktop behavior.
66	input <- ports.KeyEvent{Keysym: "raw", Pressed: true}
67	input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}}
68	input <- ports.SecurityInput{State: locked, Event: ports.SwipeBegin{Fingers: 3}}
69	input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "password", Keycode: 30, Pressed: true}}
70	expectKey(t, commands, locked, 10, "password")
71	input <- ports.SecurityInput{State: locked, Event: ports.PointerMotion{X: 350, Y: 30}}
72	input <- ports.SecurityInput{State: locked, Event: ports.PointerButton{Button: 0x110, Pressed: true}}
73	input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "clicked", Pressed: true}}
74	expectKey(t, commands, locked, 20, "clicked")
75	// Stale surfaces cannot replace the current lock roles. Invalid logical
76	// dimensions and unknown outputs never become drawn/input targets.
77	client <- ports.SessionLockChanged{State: ports.SecurityState{}, Surfaces: []ports.LockSurfacePlacement{{ID: 1, Output: "A", Width: 300, Height: 200}}}
78	client <- ports.SessionLockChanged{State: locked, Surfaces: append(surfaces, ports.LockSurfacePlacement{ID: 99, Output: "unknown", Width: 300, Height: 200})}
79	s = sceneMatch(t, scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 && s.Windows[0].ID == 10 })
80	assertProtected(t, s)
81	cfg.Terminal.AutoOpen = "always"
82	cfg.Startup = [][]string{{"must-not-start"}}
83	reload <- ports.ConfigChanged{Config: cfg}
84	s = scene(t, scenes)
85	assertProtected(t, s)
86	output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "C", Width: 100, Height: 100}}
87	for _, s := range receive(t, scenes) {
88		assertProtected(t, s)
89		if s.Output == "C" && len(s.Windows) != 0 {
90			t.Fatal(s)
91		}
92	}
93	select {
94	case req := <-spawn:
95		t.Fatalf("protected spawn: %+v", req)
96	default:
97	}
98	// Unlock discards all presses/grabs, including a password press with no
99	// corresponding release until AFTER the transition.
100	unlocked := ports.SecurityState{Generation: 2}
101	epoch.Store(unlocked)
102	input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "stale-password", Pressed: true}}
103	input <- ports.SecurityInput{State: unlocked, Event: ports.KeyEvent{Keysym: "password", Keycode: 30, Pressed: false}}
104	input <- ports.SecurityInput{State: unlocked, Event: ports.KeyEvent{Keysym: "desktop", Pressed: true}}
105	expectKey(t, commands, unlocked, 1, "desktop")
106	s = sceneMatch(t, scenes, func(s ports.Scene) bool { return s.Security == unlocked })
107	if len(s.Windows) == 0 || s.Windows[0].ID != 1 {
108		t.Fatal(s)
109	}
110}
111
112func assertProtected(t *testing.T, s ports.Scene) {
113	t.Helper()
114	if !s.Security.Protected || s.Background != "#000000" || s.Capture != nil || s.CaptureScene != nil || len(s.Layers) != 0 || len(s.Separators) != 0 || s.Border.Width != 0 || s.Dim != 0 || s.WorkspaceClip != (ports.Rect{}) {
115		t.Fatalf("unsafe scene: %+v", s)
116	}
117	for _, w := range s.Windows {
118		if !w.Fullscreen || w.Hidden || w.Popup || w.Rect != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) {
119			t.Fatalf("unsafe role: %+v", w)
120		}
121	}
122}
123
124func expectKey(t *testing.T, ch <-chan ports.ClientCommand, state ports.SecurityState, id ports.WindowID, name string) {
125	t.Helper()
126	for {
127		wrapped, ok := receive(t, ch).(ports.SecurityCommand)
128		if !ok {
129			t.Fatal("unwrapped command")
130		}
131		if k, ok := wrapped.Command.(ports.ForwardKey); ok {
132			if wrapped.State != state {
133				continue
134			} // older commands keep their epoch
135			if k.Key.Keysym == "raw" || k.Key.Keysym == "stale-password" || !k.Key.Pressed {
136				t.Fatalf("leaked input: %+v", k)
137			}
138			if k.Key.Keysym == name {
139				if k.ID != id {
140					t.Fatalf("key target %d want %d", k.ID, id)
141				}
142				return
143			}
144		}
145	}
146}

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