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}