1package headlessinput
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "strings"
8 "sync"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "github.com/bnema/neferwl/internal/adapters/xkb"
14 "github.com/bnema/neferwl/internal/logging"
15 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
16 "github.com/bnema/neferwl/internal/ports"
17 "github.com/bnema/zerowrap"
18)
19
20func TestSecureScriptSnapshotsEveryInputKind(t *testing.T) {
21 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
22 if err != nil {
23 t.Skip(err)
24 }
25 script := make(chan string, 4)
26 for _, line := range []string{"key A", "move 10 20", "click", "swipe left"} {
27 script <- line
28 }
29 close(script)
30 input := make(chan ports.InputEvent, 32)
31 security := portsmocks.NewMockSessionSecurity(t)
32 state := ports.SecurityState{Generation: 7, Protected: true}
33 security.EXPECT().Snapshot().Return(state)
34 if err := RunSecure(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input"), security); err != nil {
35 t.Fatal(err)
36 }
37 kinds := map[string]bool{}
38 for len(input) > 0 {
39 raw := <-input
40 ev, ok := raw.(ports.SecurityInput)
41 if !ok || ev.State != state {
42 t.Fatalf("unstamped event %T %+v", raw, raw)
43 }
44 switch ev.Event.(type) {
45 case ports.KeyEvent:
46 kinds["key"] = true
47 case ports.PointerMotion:
48 kinds["motion"] = true
49 case ports.PointerButton:
50 kinds["button"] = true
51 case ports.SwipeBegin:
52 kinds["begin"] = true
53 case ports.SwipeUpdate:
54 kinds["update"] = true
55 case ports.SwipeEnd:
56 kinds["end"] = true
57 default:
58 t.Fatalf("unexpected input %T", ev.Event)
59 }
60 }
61 if len(kinds) != 6 {
62 t.Fatalf("input kinds %v", kinds)
63 }
64}
65
66// securityLogBuffer is a real synchronized io.Writer, not a logging double.
67type securityLogBuffer struct {
68 mu sync.Mutex
69 buf bytes.Buffer
70}
71
72func (b *securityLogBuffer) Write(p []byte) (int, error) {
73 b.mu.Lock()
74 defer b.mu.Unlock()
75 return b.buf.Write(p)
76}
77
78func (b *securityLogBuffer) String() string {
79 b.mu.Lock()
80 defer b.mu.Unlock()
81 return b.buf.String()
82}
83
84func TestSecureScriptDiagnosticsRedactProtectedPayloads(t *testing.T) {
85 lines := []string{
86 "type 🔐", "key credential-secret", "key credential-modifier+A",
87 "move credential-coordinate 2", "swipe credential-direction",
88 "click credential-button", "sleep credential-duration", "credential-full-command",
89 }
90 for _, protected := range []bool{false, true} {
91 name := "unlocked"
92 if protected {
93 name = "protected"
94 }
95 t.Run(name, func(t *testing.T) {
96 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
97 if err != nil {
98 t.Skip(err)
99 }
100 security := portsmocks.NewMockSessionSecurity(t)
101 security.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: protected}).Times(len(lines))
102 var buf securityLogBuffer
103 log := zerowrap.Logger{Logger: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}).With().Str("component", "input").Logger()}
104 script := make(chan string, len(lines))
105 for _, line := range lines {
106 script <- line
107 }
108 close(script)
109 if err := RunSecure(context.Background(), km, nil, script, make(chan ports.InputEvent, 32), nil, nil, log, security); err != nil {
110 t.Fatal(err)
111 }
112 output := buf.String()
113 entries := strings.Split(strings.TrimSpace(output), "\n")
114 if len(entries) != len(lines) {
115 t.Fatalf("diagnostics missing: %s", output)
116 }
117 if protected {
118 for _, secret := range []string{"🔐", "credential", "type 🔐", "key credential-secret", "credential-full-command"} {
119 if strings.Contains(output, secret) {
120 t.Fatalf("protected credential %q logged: %s", secret, output)
121 }
122 }
123 for _, entry := range entries {
124 var fields map[string]any
125 if err := json.Unmarshal([]byte(entry), &fields); err != nil {
126 t.Fatal(err)
127 }
128 if fields["message"] != "invalid script input" {
129 t.Fatalf("input-derived diagnostic: %s", entry)
130 }
131 for _, field := range []string{"rune", "key", "combo", "line"} {
132 if _, exists := fields[field]; exists {
133 t.Fatalf("protected field %s: %s", field, entry)
134 }
135 }
136 }
137 } else {
138 for _, value := range []string{"🔐", "credential-secret", "credential-full-command", "unknown rune", "unknown key", "invalid move"} {
139 if !strings.Contains(output, value) {
140 t.Fatalf("unlocked diagnostic %q changed: %s", value, output)
141 }
142 }
143 }
144 })
145 }
146}
147
148func TestSecureScriptSnapshotsBeforeKeyTranslation(t *testing.T) {
149 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
150 if err != nil {
151 t.Skip(err)
152 }
153 security := portsmocks.NewMockSessionSecurity(t)
154 state := ports.SecurityState{Generation: 7, Protected: true}
155 security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
156 // The first emitted event presses Shift. If snapshot happened after
157 // km.Key, the live keymap would already contain the Shift modifier.
158 if ev := km.Key(30, false, 0); ev.Mods&ports.ModShift != 0 {
159 t.Error("snapshot taken after key translation updated Shift")
160 }
161 return state
162 }).Once()
163 security.EXPECT().Snapshot().Return(state).Times(3)
164 script := make(chan string, 1)
165 script <- "key Shift+A"
166 close(script)
167 input := make(chan ports.InputEvent, 4)
168 if err := RunSecure(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input"), security); err != nil {
169 t.Fatal(err)
170 }
171 if len(input) != 4 {
172 t.Fatalf("events=%d", len(input))
173 }
174 first := (<-input).(ports.SecurityInput)
175 if first.State != state || first.Event.(ports.KeyEvent).Keycode != 42 {
176 t.Fatalf("first event %#v", first)
177 }
178}
179
180func TestSecureScriptBlockedSendPreservesProductionSnapshot(t *testing.T) {
181 for _, line := range []string{"key a", "move 1 2"} {
182 t.Run(line, func(t *testing.T) {
183 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
184 if err != nil {
185 t.Skip(err)
186 }
187 original := ports.SecurityState{Generation: 1, Protected: true}
188 changed := ports.SecurityState{Generation: 2}
189 security := portsmocks.NewMockSessionSecurity(t)
190 var current atomic.Pointer[ports.SecurityState]
191 current.Store(&original)
192 produced := make(chan struct{})
193 security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
194 state := *current.Load()
195 close(produced)
196 return state
197 }).Once()
198 if line == "key a" {
199 security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *current.Load() }).Once()
200 }
201 script := make(chan string, 1)
202 script <- line
203 close(script)
204 input := make(chan ports.InputEvent) // no receiver until gate changes
205 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
206 defer cancel()
207 done := make(chan error, 1)
208 go func() { done <- RunSecure(ctx, km, nil, script, input, nil, nil, logging.For(ctx, "input"), security) }()
209 select {
210 case <-produced:
211 case <-ctx.Done():
212 t.Fatal("no production snapshot")
213 }
214 current.Store(&changed)
215 select {
216 case raw := <-input:
217 if ev, ok := raw.(ports.SecurityInput); !ok || ev.State != original {
218 t.Fatalf("blocked event restamped: %#v", raw)
219 }
220 case <-ctx.Done():
221 t.Fatal("blocked event not delivered")
222 }
223 // The release of a key held across the transition is quarantined.
224 // It must not be delivered into the new epoch.
225
226 select {
227 case err := <-done:
228 if err != nil {
229 t.Fatal(err)
230 }
231 case <-ctx.Done():
232 t.Fatal("script did not stop")
233 }
234 })
235 }
236}
237
238func TestSecureScriptQuarantinesModifierAcrossTransition(t *testing.T) {
239 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
240 if err != nil {
241 t.Fatal(err)
242 }
243 initial := ports.SecurityState{}
244 locked := ports.SecurityState{Generation: 1, Protected: true}
245 security := portsmocks.NewMockSessionSecurity(t)
246 security.EXPECT().Snapshot().Return(initial).Once() // Super down
247 security.EXPECT().Snapshot().Return(locked).Times(5) // q down/up, held Super up, fresh q down/up
248 script := make(chan string, 2)
249 script <- "key Super+q"
250 script <- "key q"
251 close(script)
252 input := make(chan ports.InputEvent, 8)
253 if err := RunSecure(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input"), security); err != nil {
254 t.Fatal(err)
255 }
256 if len(input) != 5 {
257 t.Fatalf("quarantined release replayed: %d events", len(input))
258 }
259 first := (<-input).(ports.SecurityInput)
260 if first.State != initial || first.Event.(ports.KeyEvent).Keycode != 125 {
261 t.Fatalf("first event %#v", first)
262 }
263 for len(input) > 0 {
264 ev := (<-input).(ports.SecurityInput)
265 key := ev.Event.(ports.KeyEvent)
266 if ev.State != locked || key.Keycode != 16 || key.Mods != 0 || key.State != (ports.ModState{}) {
267 t.Fatalf("script password inherited modifiers: %#v", ev)
268 }
269 }
270}