session_env_test.go RawHistoryFolder

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}

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 12

Tags 11

↑↓ moveenter openesc close