security_test.go RawHistoryFolder

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

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