1package daemon
2
3import (
4 "encoding/json"
5 "slices"
6 "strings"
7
8 "github.com/bnema/vev/internal/protocol"
9)
10
11// Display variables describe one graphical session and move as a group: a
12// client with a non-empty WAYLAND_DISPLAY or DISPLAY replaces the whole group and removes
13// members it lacks, so a switch to another compositor or an X11-only desktop
14// never leaves a stale socket behind. A client without a display (console)
15// or reached over SSH leaves the group untouched: an X-forwarded DISPLAY
16// belongs to that one connection and must not replace the local desktop.
17var sessionDisplayEnvironment = []string{
18 "WAYLAND_DISPLAY",
19 "DISPLAY",
20 "XAUTHORITY",
21 "XDG_CURRENT_DESKTOP",
22 "XDG_SESSION_TYPE",
23 "XDG_SESSION_DESKTOP",
24 "XDG_SESSION_CLASS",
25 "XDG_SESSION_ID",
26}
27
28// User variables belong to the user login rather than one display: with
29// systemd, every graphical session of a user shares the runtime dir and user
30// bus. They are set when the client has them and never removed.
31var sessionUserEnvironment = []string{
32 "DBUS_SESSION_BUS_ADDRESS",
33 "XDG_RUNTIME_DIR",
34}
35
36// Login variables follow the latest attach exactly: a value the client lacks is
37// removed, because a stale agent socket or SSH peer is worse than none.
38var sessionLoginEnvironment = []string{
39 "SSH_AUTH_SOCK",
40 "SSH_AGENT_PID",
41 "SSH_CONNECTION",
42 "SSH_CLIENT",
43 "SSH_TTY",
44}
45
46// setClientEnvironment records the attachment's own client-owned environment;
47// nil means the daemon owns it and the attachment never changes session env.
48func (ac *attachedClient) setClientEnvironment(env []string) {
49 if env == nil {
50 ac.clientEnv.Store(nil)
51 return
52 }
53 owned := append(make([]string, 0, len(env)), env...)
54 ac.clientEnv.Store(&owned)
55}
56
57// clientEnvironment returns the attachment's client-owned environment, or
58// ok=false when the daemon owns it. The slice is immutable; do not modify it.
59func (ac *attachedClient) clientEnvironment() ([]string, bool) {
60 env := ac.clientEnv.Load()
61 if env == nil {
62 return nil, false
63 }
64 return *env, true
65}
66
67// helloClientEnvironment returns the Hello environment when the client owns
68// it, or nil for a daemon-owned (remote) attach.
69func helloClientEnvironment(h protocol.Hello) []string {
70 if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
71 return nil
72 }
73 if h.Env == nil {
74 return []string{}
75 }
76 return h.Env
77}
78
79// environmentSeedLocked returns a copy of the session environment for a new
80// session seeded from this one, and whether it is still the daemon's own
81// (provisional). The caller holds s.mu.
82func (s *session) environmentSeedLocked() ([]string, bool) {
83 return copyEnvironment(s.env), s.envProvisional
84}
85
86// markEnvironmentProvisional records that sess.env came from the daemon's own
87// process, so the first client-owned environment replaces it.
88func markEnvironmentProvisional(sess *session) {
89 sess.mu.Lock()
90 sess.envProvisional = true
91 sess.mu.Unlock()
92}
93
94// adoptClientEnvironmentLocked applies a client-owned environment to the
95// session's future PTY children. The caller holds sess.mu.
96//
97// A provisional environment (the daemon's own, see envProvisional) is replaced
98// wholesale, except that a client without its own display keeps the session's
99// display group when it has one, matching refreshSessionEnvironment.
100func (s *session) adoptClientEnvironmentLocked(client []string) {
101 if !s.envProvisional {
102 s.env = refreshSessionEnvironment(s.env, client)
103 return
104 }
105 next := copyEnvironment(client)
106 if !ownsDisplay(client) && hasDisplay(s.env) {
107 next = replaceEnvironmentGroup(next, s.env, sessionDisplayEnvironment)
108 }
109 s.env = next
110 s.envProvisional = false
111}
112
113// refreshSessionEnvironment returns current with its session-bound variables
114// refreshed from client. Entries keep their position; duplicates of a refreshed
115// variable collapse into its first entry, and new variables are appended.
116func refreshSessionEnvironment(current, client []string) []string {
117 if ownsDisplay(client) {
118 current = replaceEnvironmentGroup(current, client, sessionDisplayEnvironment)
119 }
120 for _, key := range sessionUserEnvironment {
121 if value, ok := lookupEnvironment(client, key); ok {
122 current = setEnvironment(current, key, &value)
123 }
124 }
125 return replaceEnvironmentGroup(current, client, sessionLoginEnvironment)
126}
127
128// replaceEnvironmentGroup sets every key of group in env to its value in
129// source, removing keys source lacks. It returns a new slice.
130func replaceEnvironmentGroup(env, source, group []string) []string {
131 for _, key := range group {
132 if value, ok := lookupEnvironment(source, key); ok {
133 env = setEnvironment(env, key, &value)
134 } else {
135 env = setEnvironment(env, key, nil)
136 }
137 }
138 return env
139}
140
141// setEnvironment returns a copy of env with key set to *value in place of its
142// first entry (appended when absent), or removed when value is nil. Later
143// duplicates of key are dropped. env itself is never modified.
144func setEnvironment(env []string, key string, value *string) []string {
145 out := make([]string, 0, len(env)+1)
146 found := false
147 for _, entry := range env {
148 if name, _, _ := environmentEntry(entry); name == key {
149 if !found && value != nil {
150 out = append(out, key+"="+*value)
151 }
152 found = true
153 continue
154 }
155 out = append(out, entry)
156 }
157 if !found && value != nil {
158 out = append(out, key+"="+*value)
159 }
160 return out
161}
162
163// ownsDisplay reports whether client is a local graphical session whose
164// display group should replace the session's. SSH clients never do, even with
165// X forwarding.
166func ownsDisplay(client []string) bool {
167 if _, ssh := lookupEnvironment(client, "SSH_CONNECTION"); ssh {
168 return false
169 }
170 return hasDisplay(client)
171}
172
173// hasDisplay reports whether env belongs to a graphical session. An empty
174// value names no display (it usually means "no X here"), so it does not count.
175func hasDisplay(env []string) bool {
176 wayland, _ := lookupEnvironment(env, "WAYLAND_DISPLAY")
177 x11, _ := lookupEnvironment(env, "DISPLAY")
178 return wayland != "" || x11 != ""
179}
180
181// sessionBoundEnvironment lists the variables that follow the attaching client.
182// A local client-owned attach refreshes only these; every other variable
183// (SHELL, PATH, HOME, ...) keeps the value the session was created with.
184var sessionBoundEnvironment = slices.Concat(sessionDisplayEnvironment, sessionUserEnvironment, sessionLoginEnvironment)
185
186// lookupEnvironment returns the first value for key, matching getenv(3).
187func lookupEnvironment(env []string, key string) (string, bool) {
188 for _, entry := range env {
189 if name, value, ok := environmentEntry(entry); ok && name == key {
190 return value, true
191 }
192 }
193 return "", false
194}
195
196// sessionEnvironmentExport renders the session-bound variables a running shell
197// should apply. JSON maps each variable to its value, or null when the shell
198// should unset it; it is the machine format. Text output is one KEY=value line
199// per set variable, for people.
200// Missing login variables are always unset. Missing display variables are
201// unset only when the session has a display; otherwise they are omitted, so a
202// shell keeps any value it obtained on its own.
203func sessionEnvironmentExport(env []string, asJSON bool) (string, error) {
204 values := make(map[string]*string)
205 var text strings.Builder
206 for _, key := range sessionBoundEnvironment {
207 if value, ok := lookupEnvironment(env, key); ok {
208 values[key] = &value
209 text.WriteString(key + "=" + value + "\n")
210 }
211 }
212 unset := sessionLoginEnvironment
213 if hasDisplay(env) {
214 unset = slices.Concat(sessionDisplayEnvironment, sessionLoginEnvironment)
215 }
216 for _, key := range unset {
217 if _, ok := values[key]; !ok {
218 values[key] = nil
219 }
220 }
221 if !asJSON {
222 return text.String(), nil
223 }
224 encoded, err := json.Marshal(values)
225 if err != nil {
226 return "", err
227 }
228 return string(encoded) + "\n", nil
229}