1package daemon
2
3import (
4 "testing"
5
6 "github.com/bnema/vev/internal/ports"
7 "github.com/bnema/vev/internal/protocol"
8 snapcodec "github.com/bnema/vev/internal/snapshotcodec"
9 "github.com/stretchr/testify/require"
10)
11
12func TestRefreshSessionEnvironment(t *testing.T) {
13 tests := []struct {
14 name string
15 current []string
16 client []string
17 want []string
18 }{
19 {
20 name: "refreshing from the creating environment keeps it byte-identical",
21 current: []string{"A=1", "WAYLAND_DISPLAY=wayland-1", "PATH=/a", "DBUS_SESSION_BUS_ADDRESS=bus", "SSH_AUTH_SOCK=/agent", "Z=2"},
22 client: []string{"A=1", "WAYLAND_DISPLAY=wayland-1", "PATH=/a", "DBUS_SESSION_BUS_ADDRESS=bus", "SSH_AUTH_SOCK=/agent", "Z=2"},
23 want: []string{"A=1", "WAYLAND_DISPLAY=wayland-1", "PATH=/a", "DBUS_SESSION_BUS_ADDRESS=bus", "SSH_AUTH_SOCK=/agent", "Z=2"},
24 },
25 {
26 name: "ordinary variables keep creation values",
27 current: []string{"SHELL=/usr/bin/fish", "PATH=/a", "HOME=/home/user"},
28 client: []string{"SHELL=/bin/bash", "PATH=/b", "NEW=1"},
29 want: []string{"SHELL=/usr/bin/fish", "PATH=/a", "HOME=/home/user"},
30 },
31 {
32 name: "graphical attach adds the display group to a console session",
33 current: []string{"SHELL=/usr/bin/fish", "XDG_RUNTIME_DIR=/run/user/1000"},
34 client: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "XDG_RUNTIME_DIR=/run/user/1000", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"},
35 want: []string{"SHELL=/usr/bin/fish", "XDG_RUNTIME_DIR=/run/user/1000", "WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"},
36 },
37 {
38 name: "attach without a display leaves the display group untouched",
39 current: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "XDG_SESSION_TYPE=wayland", "KEEP=1"},
40 client: []string{"XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=7", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
41 want: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "XDG_SESSION_TYPE=wayland", "KEEP=1", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
42 },
43 {
44 name: "ssh with x forwarding leaves the local display group untouched",
45 current: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland", "XDG_SESSION_ID=2"},
46 client: []string{"DISPLAY=localhost:10.0", "XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=9", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22", "SSH_AUTH_SOCK=/tmp/fwd-agent"},
47 want: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland", "XDG_SESSION_ID=2", "SSH_AUTH_SOCK=/tmp/fwd-agent", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
48 },
49 {
50 name: "second compositor on another console replaces the whole display group",
51 current: []string{"KEEP=1", "WAYLAND_DISPLAY=wayland-0", "DISPLAY=:0", "XDG_CURRENT_DESKTOP=first", "XDG_SESSION_ID=2"},
52 client: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_CURRENT_DESKTOP=second", "XDG_SESSION_ID=3"},
53 want: []string{"KEEP=1", "WAYLAND_DISPLAY=wayland-1", "XDG_CURRENT_DESKTOP=second", "XDG_SESSION_ID=3"},
54 },
55 {
56 name: "x11-only desktop removes the stale wayland socket",
57 current: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland"},
58 client: []string{"DISPLAY=:1", "XAUTHORITY=/tmp/xauth", "XDG_SESSION_TYPE=x11"},
59 want: []string{"XDG_SESSION_TYPE=x11", "DISPLAY=:1", "XAUTHORITY=/tmp/xauth"},
60 },
61 {
62 name: "user bus variables follow a client that has them",
63 current: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/old/bus", "XDG_RUNTIME_DIR=/old"},
64 client: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000"},
65 want: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000"},
66 },
67 {
68 name: "user bus variables are kept by an attach without them",
69 current: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000"},
70 client: []string{"WAYLAND_DISPLAY=wayland-1"},
71 want: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000", "WAYLAND_DISPLAY=wayland-1"},
72 },
73 {
74 name: "login variables absent from the client are removed",
75 current: []string{"SSH_AUTH_SOCK=/tmp/stale", "SSH_CONNECTION=old", "SSH_TTY=/dev/pts/1", "KEEP=1"},
76 client: []string{"WAYLAND_DISPLAY=wayland-1"},
77 want: []string{"KEEP=1", "WAYLAND_DISPLAY=wayland-1"},
78 },
79 {
80 name: "agent pid follows the agent socket",
81 current: []string{"SSH_AUTH_SOCK=/tmp/old", "SSH_AGENT_PID=11"},
82 client: []string{"SSH_AUTH_SOCK=/tmp/new"},
83 want: []string{"SSH_AUTH_SOCK=/tmp/new"},
84 },
85 {
86 name: "duplicate current entries collapse in place and the first client value wins",
87 current: []string{"WAYLAND_DISPLAY=a", "WAYLAND_DISPLAY=b", "PAIR=a=b"},
88 client: []string{"WAYLAND_DISPLAY=c", "WAYLAND_DISPLAY=d"},
89 want: []string{"WAYLAND_DISPLAY=c", "PAIR=a=b"},
90 },
91 {
92 name: "an empty display value is not a graphical client",
93 current: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0"},
94 client: []string{"DISPLAY=", "WAYLAND_DISPLAY="},
95 want: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0"},
96 },
97 {
98 name: "an empty member of a graphical client's group is copied",
99 current: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0"},
100 client: []string{"WAYLAND_DISPLAY=wayland-2", "DISPLAY="},
101 want: []string{"WAYLAND_DISPLAY=wayland-2", "DISPLAY="},
102 },
103 }
104 for _, tt := range tests {
105 t.Run(tt.name, func(t *testing.T) {
106 current := append([]string(nil), tt.current...)
107 got := refreshSessionEnvironment(current, tt.client)
108 require.Equal(t, tt.want, got)
109 require.Equal(t, tt.current, current, "the previous snapshot is never mutated")
110 })
111 }
112}
113
114func TestSessionEnvironmentExport(t *testing.T) {
115 tests := []struct {
116 name string
117 env []string
118 asJSON bool
119 want string
120 }{
121 {
122 name: "text lists set session-bound variables only",
123 env: []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-1", "SSH_AUTH_SOCK=/run/agent", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/bus"},
124 want: "WAYLAND_DISPLAY=wayland-1\nDBUS_SESSION_BUS_ADDRESS=unix:path=/run/bus\nSSH_AUTH_SOCK=/run/agent\n",
125 },
126 {
127 name: "json with a display unsets the rest of the display group",
128 env: []string{"WAYLAND_DISPLAY=wayland-1", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/bus", "SSH_AUTH_SOCK=/run/agent"},
129 asJSON: true,
130 want: `{"DBUS_SESSION_BUS_ADDRESS":"unix:path=/run/bus","DISPLAY":null,"SSH_AGENT_PID":null,"SSH_AUTH_SOCK":"/run/agent","SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"WAYLAND_DISPLAY":"wayland-1","XAUTHORITY":null,"XDG_CURRENT_DESKTOP":null,"XDG_SESSION_CLASS":null,"XDG_SESSION_DESKTOP":null,"XDG_SESSION_ID":null,"XDG_SESSION_TYPE":null}` + "\n",
131 },
132 {
133 name: "json without a display leaves display variables alone",
134 env: []string{"SHELL=/bin/sh", "XDG_RUNTIME_DIR=/run/user/1000"},
135 asJSON: true,
136 want: `{"SSH_AGENT_PID":null,"SSH_AUTH_SOCK":null,"SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"XDG_RUNTIME_DIR":"/run/user/1000"}` + "\n",
137 },
138 {
139 name: "json with only x11 unsets a stale wayland socket",
140 env: []string{"DISPLAY=:1"},
141 asJSON: true,
142 want: `{"DISPLAY":":1","SSH_AGENT_PID":null,"SSH_AUTH_SOCK":null,"SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"WAYLAND_DISPLAY":null,"XAUTHORITY":null,"XDG_CURRENT_DESKTOP":null,"XDG_SESSION_CLASS":null,"XDG_SESSION_DESKTOP":null,"XDG_SESSION_ID":null,"XDG_SESSION_TYPE":null}` + "\n",
143 },
144 {
145 name: "empty session exports nothing as text",
146 env: []string{"SHELL=/bin/sh"},
147 want: "",
148 },
149 }
150 for _, tt := range tests {
151 t.Run(tt.name, func(t *testing.T) {
152 got, err := sessionEnvironmentExport(tt.env, tt.asJSON)
153 require.NoError(t, err)
154 require.Equal(t, tt.want, got)
155 })
156 }
157}
158
159func TestAdoptClientEnvironmentLocked(t *testing.T) {
160 tests := []struct {
161 name string
162 env []string
163 provisional bool
164 client []string
165 want []string
166 wantProvisional bool
167 }{
168 {
169 name: "provisional daemon environment is replaced wholesale once",
170 env: []string{"SHELL=/bin/sh", "PATH=/daemon"},
171 provisional: true,
172 client: []string{"SHELL=/usr/bin/fish", "PATH=/client", "WAYLAND_DISPLAY=wayland-1"},
173 want: []string{"SHELL=/usr/bin/fish", "PATH=/client", "WAYLAND_DISPLAY=wayland-1"},
174 },
175 {
176 name: "provisional session keeps its display for an ssh client with x forwarding",
177 env: []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland"},
178 provisional: true,
179 client: []string{"PATH=/ssh", "DISPLAY=localhost:10.0", "XDG_SESSION_TYPE=tty", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
180 want: []string{"PATH=/ssh", "XDG_SESSION_TYPE=wayland", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22", "WAYLAND_DISPLAY=wayland-1"},
181 },
182 {
183 name: "provisional session keeps its display for a console client",
184 env: []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1"},
185 provisional: true,
186 client: []string{"PATH=/console"},
187 want: []string{"PATH=/console", "WAYLAND_DISPLAY=wayland-1"},
188 },
189 {
190 name: "provisional session without a display keeps the console client's session variables",
191 env: []string{"PATH=/daemon"},
192 provisional: true,
193 client: []string{"PATH=/console", "XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=4"},
194 want: []string{"PATH=/console", "XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=4"},
195 },
196 {
197 name: "client environment refreshes only session-bound variables",
198 env: []string{"SHELL=/usr/bin/fish", "PATH=/first"},
199 client: []string{"WAYLAND_DISPLAY=wayland-1", "SHELL=/bin/bash", "PATH=/second"},
200 want: []string{"SHELL=/usr/bin/fish", "PATH=/first", "WAYLAND_DISPLAY=wayland-1"},
201 },
202 }
203 for _, tt := range tests {
204 t.Run(tt.name, func(t *testing.T) {
205 client := append([]string(nil), tt.client...)
206 sess := &session{env: tt.env, envProvisional: tt.provisional}
207 sess.adoptClientEnvironmentLocked(client)
208 require.Equal(t, tt.want, sess.env)
209 require.Equal(t, tt.wantProvisional, sess.envProvisional)
210 // client[0] is always copied (provisional) or session-bound
211 // (refresh), so mutating it proves the session owns its entries.
212 client[0] = "WAYLAND_DISPLAY=mutated"
213 require.NotContains(t, sess.env, "WAYLAND_DISPLAY=mutated", "the session never aliases the client slice")
214 })
215 }
216}
217
218func TestApplyTargetStateRefreshesTargetEnvironment(t *testing.T) {
219 // The source session last saw a desktop client; the switching attachment
220 // is a separate SSH client. Only the switching attachment's own
221 // environment may reach the target.
222 sourceEnv := []string{"SHELL=/bin/bash", "PATH=/source", "WAYLAND_DISPLAY=wayland-desktop", "SSH_AUTH_SOCK=/run/desktop-agent"}
223 sshEnv := []string{"SHELL=/bin/zsh", "PATH=/ssh", "SSH_AUTH_SOCK=/tmp/ssh-agent", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"}
224 targetEnv := []string{"SHELL=/usr/bin/fish", "PATH=/target", "WAYLAND_DISPLAY=wayland-old"}
225 tests := []struct {
226 name string
227 preserve bool
228 provisional bool
229 clientEnv []string
230 want []string
231 }{
232 {
233 name: "handoff refreshes from the switching attachment, not the source session",
234 clientEnv: sshEnv,
235 want: []string{"SHELL=/usr/bin/fish", "PATH=/target", "WAYLAND_DISPLAY=wayland-old", "SSH_AUTH_SOCK=/tmp/ssh-agent", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
236 },
237 {
238 name: "ssh handoff into a restored session adopts it but keeps the local display",
239 provisional: true,
240 clientEnv: sshEnv,
241 want: append(append([]string(nil), sshEnv...), "WAYLAND_DISPLAY=wayland-old"),
242 },
243 {
244 name: "daemon-owned attachment never changes the target",
245 want: targetEnv,
246 },
247 {
248 name: "daemon-owned attachment keeps a restored target provisional",
249 provisional: true,
250 want: targetEnv,
251 },
252 {
253 name: "a preserved attachment leaves the target untouched",
254 preserve: true,
255 clientEnv: sshEnv,
256 want: targetEnv,
257 },
258 }
259 for _, tt := range tests {
260 t.Run(tt.name, func(t *testing.T) {
261 source := &session{env: copyEnvironment(sourceEnv)}
262 target := &session{env: copyEnvironment(targetEnv), envProvisional: tt.provisional}
263 next := &attachedClient{}
264 next.setClientEnvironment(tt.clientEnv)
265 d := &Daemon{}
266 d.applyTargetStateLocked(&attachmentPublication{
267 req: attachmentTransitionRequest{target: target, next: next, preserveAttachment: tt.preserve},
268 source: source,
269 })
270 require.Equal(t, tt.want, target.env)
271 require.Equal(t, tt.provisional && (tt.clientEnv == nil || tt.preserve), target.envProvisional)
272 require.Equal(t, sourceEnv, source.env, "the source session is never read or changed")
273 })
274 }
275}
276
277func TestAttachedClientEnvironment(t *testing.T) {
278 tests := []struct {
279 name string
280 hello protocol.Hello
281 want []string
282 wantOK bool
283 }{
284 {
285 name: "client-owned hello is recorded",
286 hello: protocol.Hello{Env: []string{"A=1"}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned},
287 want: []string{"A=1"},
288 wantOK: true,
289 },
290 {
291 name: "client-owned empty hello still owns an empty environment",
292 hello: protocol.Hello{EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned},
293 want: []string{},
294 wantOK: true,
295 },
296 {
297 name: "daemon-owned hello records nothing",
298 hello: protocol.Hello{Env: []string{"A=1"}, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned},
299 },
300 }
301 for _, tt := range tests {
302 t.Run(tt.name, func(t *testing.T) {
303 hello := tt.hello
304 hello.Env = append([]string(nil), tt.hello.Env...)
305 ac := &attachedClient{}
306 ac.setClientEnvironment(helloClientEnvironment(hello))
307 got, ok := ac.clientEnvironment()
308 require.Equal(t, tt.wantOK, ok)
309 require.Equal(t, tt.want, got)
310 if ok && len(hello.Env) > 0 {
311 hello.Env[0] = "MUTATED=1"
312 again, _ := ac.clientEnvironment()
313 require.Equal(t, tt.want, again, "the attachment owns a private copy")
314 }
315 })
316 }
317}
318
319func TestNewRestoredSessionEnvironmentIsProvisional(t *testing.T) {
320 d := newTestDaemon(t, nil, stubClock{})
321 d.baseEnv = []string{"SHELL=/bin/sh", "PATH=/daemon"}
322 sess := d.newRestoredSession(snapcodec.Session{Name: "work"}, d.serveCtx, func() {}, nil)
323 require.Equal(t, d.baseEnv, sess.env)
324 require.True(t, sess.envProvisional, "the first client-owned attach must replace the daemon's environment")
325}
326
327// TestFailedAttachTransitionLeavesSessionEnvironment proves a real attach
328// applies the environment inside the publication: when the transition fails
329// after the attachment and its client environment are prepared, the session
330// environment and its provisional state stay untouched.
331func TestFailedAttachTransitionLeavesSessionEnvironment(t *testing.T) {
332 d := newTestDaemon(t, nil, stubClock{})
333 sess := addControlSession(d, "work", "tab-1", "pane-1")
334 sess.ephemeral = false
335 sess.incarnation = newTestLifecycle(t)
336 sess.mu.Lock()
337 sess.env = []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1"}
338 sess.envProvisional = true
339 sess.mu.Unlock()
340 // Fail the transition after finishAttach prepared the attachment, where
341 // an eager adopt would already have rewritten the session.
342 d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) {
343 d.mu.Lock()
344 d.closing = true
345 d.mu.Unlock()
346 }
347
348 target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
349 env := []string{"PATH=/client", "WAYLAND_DISPLAY=wayland-2"}
350 hello := protocol.Hello{
351 Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, Size: defaultSize,
352 ExactTarget: &target, Env: env, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
353 }
354 admission := ports.SessionAdmission{
355 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
356 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Target: target, Env: env,
357 }
358 _, _, err := d.route(hello, &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: admission, present: true})
359 require.Error(t, err)
360
361 sess.mu.Lock()
362 defer sess.mu.Unlock()
363 require.Equal(t, []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1"}, sess.env)
364 require.True(t, sess.envProvisional)
365}
366
367func TestEnvironmentSeedCarriesProvisionalState(t *testing.T) {
368 tests := []struct {
369 name string
370 provisional bool
371 }{
372 {name: "client-owned source seeds a client-owned session"},
373 {name: "provisional source seeds a provisional session", provisional: true},
374 }
375 for _, tt := range tests {
376 t.Run(tt.name, func(t *testing.T) {
377 factory := &controlPTYFactory{}
378 d := newTestDaemon(t, factory, stubClock{})
379 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
380 source := addControlSession(d, "work", "t_work", "p_work")
381 source.mu.Lock()
382 source.env = []string{"PATH=/source"}
383 source.envProvisional = tt.provisional
384 source.mu.Unlock()
385
386 result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"seeded"}, TargetSession: "work"})
387 require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
388 d.mu.Lock()
389 seeded := d.findByNameLocked("seeded")
390 d.mu.Unlock()
391 require.NotNil(t, seeded)
392 seeded.mu.Lock()
393 defer seeded.mu.Unlock()
394 require.Equal(t, []string{"PATH=/source"}, seeded.env)
395 require.Equal(t, tt.provisional, seeded.envProvisional)
396 })
397 }
398}