fix(logind): fall back to the user's live graphical session (#10)
A terminal started by the compositor runs outside any session scope and can inherit a stale XDG_SESSION_ID, so neferafk failed with "No session known". Fallback sessions (XDG_SESSION_ID, then the real UID's logind Display session) must now be wayland/x11 and active/online; the UID check still applies. The daemon logs which source bound the session.
- commit
- a514042c2c9a06eb48e0f6226d0bbea658f6d7bb
- author
- bnema · · 2026-10-02 04:00 UTC
- committer
- GitHub · · 2026-10-02 04:00 UTC
- parent
- 4e89021
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.3.2
3 files · +252 -28
M internal/adapters/logind/logind.go +111 -19
| @@ -20,6 +20,7 @@ const service = "org.freedesktop.login1" | |||
| 20 | 20 | const managerPath dbus.ObjectPath = "/org/freedesktop/login1" | |
| 21 | 21 | const managerInterface = "org.freedesktop.login1.Manager" | |
| 22 | 22 | const sessionInterface = "org.freedesktop.login1.Session" | |
| 23 | + | const userInterface = "org.freedesktop.login1.User" | |
| 23 | 24 | const callTimeout = 3 * time.Second | |
| 24 | 25 | ||
| 25 | 26 | // Adapter is single-owner: Probe, Run and Close must not execute concurrently. | |
| @@ -29,12 +30,14 @@ type Adapter struct { | |||
| 29 | 30 | requirements ports.SystemRequirements | |
| 30 | 31 | owner string | |
| 31 | 32 | session dbus.ObjectPath | |
| 32 | - | caps ports.SystemCapabilities | |
| 33 | - | signals chan *dbus.Signal | |
| 34 | - | inhibitor *os.File | |
| 35 | - | cycle uint64 | |
| 36 | - | preparing bool | |
| 37 | - | closed bool | |
| 33 | + | // sessionSource names how session was found, for diagnostics. | |
| 34 | + | sessionSource string | |
| 35 | + | caps ports.SystemCapabilities | |
| 36 | + | signals chan *dbus.Signal | |
| 37 | + | inhibitor *os.File | |
| 38 | + | cycle uint64 | |
| 39 | + | preparing bool | |
| 40 | + | closed bool | |
| 38 | 41 | } | |
| 39 | 42 | ||
| 40 | 43 | // Open connects only when requested. Calling it is unnecessary when all system | |
| @@ -79,7 +82,8 @@ func (a *Adapter) property(ctx context.Context, path dbus.ObjectPath, iface, nam | |||
| 79 | 82 | } | |
| 80 | 83 | ||
| 81 | 84 | // Probe is read-only: no Lock/Unlock/Suspend/Inhibit call. It resolves the | |
| 82 | - | // unique service owner, PID's session and its real UID; no user override exists. | |
| 85 | + | // unique service owner, the current session (PID, XDG_SESSION_ID or the user's | |
| 86 | + | // Display session) and its real UID; no user override exists. | |
| 83 | 87 | func (a *Adapter) Probe(ctx context.Context) error { | |
| 84 | 88 | bounded, cancel := context.WithTimeout(ctx, callTimeout) | |
| 85 | 89 | defer cancel() | |
| @@ -96,8 +100,10 @@ func (a *Adapter) Probe(ctx context.Context) error { | |||
| 96 | 100 | } | |
| 97 | 101 | a.owner = owner | |
| 98 | 102 | a.caps = ports.SystemCapabilities{} | |
| 103 | + | a.session, a.sessionSource = "", "" | |
| 99 | 104 | if a.requirements.Session { | |
| 100 | - | body, err = a.currentSession(ctx) | |
| 105 | + | var source string | |
| 106 | + | body, source, err = a.currentSession(ctx) | |
| 101 | 107 | if err != nil { | |
| 102 | 108 | return err | |
| 103 | 109 | } | |
| @@ -120,7 +126,7 @@ func (a *Adapter) Probe(ctx context.Context) error { | |||
| 120 | 126 | if !ok || uid != uint32(os.Getuid()) { | |
| 121 | 127 | return errors.New("session does not belong to real UID") | |
| 122 | 128 | } | |
| 123 | - | a.session = path | |
| 129 | + | a.session, a.sessionSource = path, source | |
| 124 | 130 | a.caps.Session = true | |
| 125 | 131 | } | |
| 126 | 132 | if a.requirements.Sleep { | |
| @@ -161,21 +167,107 @@ func (a *Adapter) Probe(ctx context.Context) error { | |||
| 161 | 167 | // currentSession resolves the process's session by PID. A compositor started | |
| 162 | 168 | // as a systemd user service runs outside the session scope, so its children | |
| 163 | 169 | // have no PID session; the session the display manager opened is then taken | |
| 164 | - | // from XDG_SESSION_ID. Probe checks either result against the real UID. | |
| 165 | - | func (a *Adapter) currentSession(ctx context.Context) ([]any, error) { | |
| 170 | + | // from XDG_SESSION_ID. A terminal launched by the compositor can inherit a | |
| 171 | + | // stale XDG_SESSION_ID, so the real UID's Display session is the last resort. | |
| 172 | + | // Fallback sessions must be live and graphical; Probe checks every result | |
| 173 | + | // against the real UID. It also returns which source chose the session. | |
| 174 | + | func (a *Adapter) currentSession(ctx context.Context) ([]any, string, error) { | |
| 166 | 175 | body, err := a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid())) | |
| 167 | 176 | if err == nil { | |
| 168 | - | return body, nil | |
| 177 | + | return body, "pid", nil | |
| 169 | 178 | } | |
| 170 | - | id := os.Getenv("XDG_SESSION_ID") | |
| 171 | - | if id == "" { | |
| 172 | - | return nil, fmt.Errorf("current process session unavailable: %w", err) | |
| 179 | + | errs := []error{fmt.Errorf("PID: %w", err)} | |
| 180 | + | if id := os.Getenv("XDG_SESSION_ID"); id != "" { | |
| 181 | + | path, err := a.sessionByID(ctx, id) | |
| 182 | + | if err == nil { | |
| 183 | + | err = a.liveGraphical(ctx, path) | |
| 184 | + | } | |
| 185 | + | if err == nil { | |
| 186 | + | return []any{path}, "XDG_SESSION_ID", nil | |
| 187 | + | } | |
| 188 | + | errs = append(errs, fmt.Errorf("XDG_SESSION_ID: %w", err)) | |
| 189 | + | } | |
| 190 | + | path, err := a.displaySession(ctx) | |
| 191 | + | if err == nil { | |
| 192 | + | err = a.liveGraphical(ctx, path) | |
| 193 | + | } | |
| 194 | + | if err == nil { | |
| 195 | + | return []any{path}, "user display", nil | |
| 196 | + | } | |
| 197 | + | errs = append(errs, fmt.Errorf("user display session: %w", err)) | |
| 198 | + | return nil, "", fmt.Errorf("current process session unavailable: %w", errors.Join(errs...)) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func (a *Adapter) sessionByID(ctx context.Context, id string) (dbus.ObjectPath, error) { | |
| 202 | + | body, err := a.call(ctx, managerPath, managerInterface+".GetSession", id) | |
| 203 | + | if err != nil { | |
| 204 | + | return "", err | |
| 205 | + | } | |
| 206 | + | if len(body) != 1 { | |
| 207 | + | return "", errors.New("invalid session reply") | |
| 208 | + | } | |
| 209 | + | path, ok := body[0].(dbus.ObjectPath) | |
| 210 | + | if !ok || !path.IsValid() { | |
| 211 | + | return "", errors.New("invalid session path") | |
| 212 | + | } | |
| 213 | + | return path, nil | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // liveGraphical rejects tty, closing and lingering sessions, so a fallback | |
| 217 | + | // never binds lock signals to a session the user is not looking at. "online" | |
| 218 | + | // (logged in, not foreground) is accepted so a daemon started while the user | |
| 219 | + | // is on another VT still binds; with two graphical sessions of the same user | |
| 220 | + | // a stale XDG_SESSION_ID may thus pick the background one (same UID only). | |
| 221 | + | func (a *Adapter) liveGraphical(ctx context.Context, path dbus.ObjectPath) error { | |
| 222 | + | value, err := a.property(ctx, path, sessionInterface, "Type") | |
| 223 | + | if err != nil { | |
| 224 | + | return fmt.Errorf("%s: %w", path, err) | |
| 173 | 225 | } | |
| 174 | - | body, idErr := a.call(ctx, managerPath, managerInterface+".GetSession", id) | |
| 175 | - | if idErr != nil { | |
| 176 | - | return nil, fmt.Errorf("current process session unavailable: %w; XDG_SESSION_ID: %w", err, idErr) | |
| 226 | + | if t, _ := value.(string); t != "wayland" && t != "x11" { | |
| 227 | + | return fmt.Errorf("%s: session type %q is not graphical", path, t) | |
| 177 | 228 | } | |
| 178 | - | return body, nil | |
| 229 | + | value, err = a.property(ctx, path, sessionInterface, "State") | |
| 230 | + | if err != nil { | |
| 231 | + | return fmt.Errorf("%s: %w", path, err) | |
| 232 | + | } | |
| 233 | + | if s, _ := value.(string); s != "active" && s != "online" { | |
| 234 | + | return fmt.Errorf("%s: session state %q is not live", path, s) | |
| 235 | + | } | |
| 236 | + | return nil | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // displaySession returns the real UID's primary (Display) session from | |
| 240 | + | // logind. logind prefers a graphical one; liveGraphical enforces it. | |
| 241 | + | func (a *Adapter) displaySession(ctx context.Context) (dbus.ObjectPath, error) { | |
| 242 | + | body, err := a.call(ctx, managerPath, managerInterface+".GetUser", uint32(os.Getuid())) | |
| 243 | + | if err != nil { | |
| 244 | + | return "", err | |
| 245 | + | } | |
| 246 | + | if len(body) != 1 { | |
| 247 | + | return "", errors.New("invalid user reply") | |
| 248 | + | } | |
| 249 | + | user, ok := body[0].(dbus.ObjectPath) | |
| 250 | + | if !ok || !user.IsValid() { | |
| 251 | + | return "", errors.New("invalid user path") | |
| 252 | + | } | |
| 253 | + | value, err := a.property(ctx, user, userInterface, "Display") | |
| 254 | + | if err != nil { | |
| 255 | + | return "", err | |
| 256 | + | } | |
| 257 | + | fields, ok := value.([]any) | |
| 258 | + | if !ok || len(fields) != 2 { | |
| 259 | + | return "", errors.New("invalid user Display property") | |
| 260 | + | } | |
| 261 | + | path, ok := fields[1].(dbus.ObjectPath) | |
| 262 | + | if !ok || !path.IsValid() || path == "/" { | |
| 263 | + | return "", errors.New("no display session") | |
| 264 | + | } | |
| 265 | + | return path, nil | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // Session reports the bound session path and how it was found. | |
| 269 | + | func (a *Adapter) Session() (path, source string) { | |
| 270 | + | return string(a.session), a.sessionSource | |
| 179 | 271 | } | |
| 180 | 272 | ||
| 181 | 273 | func (a *Adapter) Capabilities() ports.SystemCapabilities { | |
| @@ -20,6 +20,7 @@ const service = "org.freedesktop.login1" | |||||
| 20 | const managerPath dbus.ObjectPath = "/org/freedesktop/login1" | 20 | const managerPath dbus.ObjectPath = "/org/freedesktop/login1" | ||
| 21 | const managerInterface = "org.freedesktop.login1.Manager" | 21 | const managerInterface = "org.freedesktop.login1.Manager" | ||
| 22 | const sessionInterface = "org.freedesktop.login1.Session" | 22 | const sessionInterface = "org.freedesktop.login1.Session" | ||
| 23 | + | const userInterface = "org.freedesktop.login1.User" | |||
| 23 | const callTimeout = 3 * time.Second | 24 | const callTimeout = 3 * time.Second | ||
| 24 | 25 | ||||
| 25 | // Adapter is single-owner: Probe, Run and Close must not execute concurrently. | 26 | // Adapter is single-owner: Probe, Run and Close must not execute concurrently. | ||
| @@ -29,12 +30,14 @@ type Adapter struct { | |||||
| 29 | requirements ports.SystemRequirements | 30 | requirements ports.SystemRequirements | ||
| 30 | owner string | 31 | owner string | ||
| 31 | session dbus.ObjectPath | 32 | session dbus.ObjectPath | ||
| 32 | - | caps ports.SystemCapabilities | 33 | + | // sessionSource names how session was found, for diagnostics. |
| 33 | - | signals chan *dbus.Signal | 34 | + | sessionSource string |
| 34 | - | inhibitor *os.File | 35 | + | caps ports.SystemCapabilities |
| 35 | - | cycle uint64 | 36 | + | signals chan *dbus.Signal |
| 36 | - | preparing bool | 37 | + | inhibitor *os.File |
| 37 | - | closed bool | 38 | + | cycle uint64 |
| 39 | + | preparing bool | |||
| 40 | + | closed bool | |||
| 38 | } | 41 | } | ||
| 39 | 42 | ||||
| 40 | // Open connects only when requested. Calling it is unnecessary when all system | 43 | // Open connects only when requested. Calling it is unnecessary when all system | ||
| @@ -79,7 +82,8 @@ func (a *Adapter) property(ctx context.Context, path dbus.ObjectPath, iface, nam | |||||
| 79 | } | 82 | } | ||
| 80 | 83 | ||||
| 81 | // Probe is read-only: no Lock/Unlock/Suspend/Inhibit call. It resolves the | 84 | // Probe is read-only: no Lock/Unlock/Suspend/Inhibit call. It resolves the | ||
| 82 | - | // unique service owner, PID's session and its real UID; no user override exists. | 85 | + | // unique service owner, the current session (PID, XDG_SESSION_ID or the user's |
| 86 | + | // Display session) and its real UID; no user override exists. | |||
| 83 | func (a *Adapter) Probe(ctx context.Context) error { | 87 | func (a *Adapter) Probe(ctx context.Context) error { | ||
| 84 | bounded, cancel := context.WithTimeout(ctx, callTimeout) | 88 | bounded, cancel := context.WithTimeout(ctx, callTimeout) | ||
| 85 | defer cancel() | 89 | defer cancel() | ||
| @@ -96,8 +100,10 @@ func (a *Adapter) Probe(ctx context.Context) error { | |||||
| 96 | } | 100 | } | ||
| 97 | a.owner = owner | 101 | a.owner = owner | ||
| 98 | a.caps = ports.SystemCapabilities{} | 102 | a.caps = ports.SystemCapabilities{} | ||
| 103 | + | a.session, a.sessionSource = "", "" | |||
| 99 | if a.requirements.Session { | 104 | if a.requirements.Session { | ||
| 100 | - | body, err = a.currentSession(ctx) | 105 | + | var source string |
| 106 | + | body, source, err = a.currentSession(ctx) | |||
| 101 | if err != nil { | 107 | if err != nil { | ||
| 102 | return err | 108 | return err | ||
| 103 | } | 109 | } | ||
| @@ -120,7 +126,7 @@ func (a *Adapter) Probe(ctx context.Context) error { | |||||
| 120 | if !ok || uid != uint32(os.Getuid()) { | 126 | if !ok || uid != uint32(os.Getuid()) { | ||
| 121 | return errors.New("session does not belong to real UID") | 127 | return errors.New("session does not belong to real UID") | ||
| 122 | } | 128 | } | ||
| 123 | - | a.session = path | 129 | + | a.session, a.sessionSource = path, source |
| 124 | a.caps.Session = true | 130 | a.caps.Session = true | ||
| 125 | } | 131 | } | ||
| 126 | if a.requirements.Sleep { | 132 | if a.requirements.Sleep { | ||
| @@ -161,21 +167,107 @@ func (a *Adapter) Probe(ctx context.Context) error { | |||||
| 161 | // currentSession resolves the process's session by PID. A compositor started | 167 | // currentSession resolves the process's session by PID. A compositor started | ||
| 162 | // as a systemd user service runs outside the session scope, so its children | 168 | // as a systemd user service runs outside the session scope, so its children | ||
| 163 | // have no PID session; the session the display manager opened is then taken | 169 | // have no PID session; the session the display manager opened is then taken | ||
| 164 | - | // from XDG_SESSION_ID. Probe checks either result against the real UID. | 170 | + | // from XDG_SESSION_ID. A terminal launched by the compositor can inherit a |
| 165 | - | func (a *Adapter) currentSession(ctx context.Context) ([]any, error) { | 171 | + | // stale XDG_SESSION_ID, so the real UID's Display session is the last resort. |
| 172 | + | // Fallback sessions must be live and graphical; Probe checks every result | |||
| 173 | + | // against the real UID. It also returns which source chose the session. | |||
| 174 | + | func (a *Adapter) currentSession(ctx context.Context) ([]any, string, error) { | |||
| 166 | body, err := a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid())) | 175 | body, err := a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid())) | ||
| 167 | if err == nil { | 176 | if err == nil { | ||
| 168 | - | return body, nil | 177 | + | return body, "pid", nil |
| 169 | } | 178 | } | ||
| 170 | - | id := os.Getenv("XDG_SESSION_ID") | 179 | + | errs := []error{fmt.Errorf("PID: %w", err)} |
| 171 | - | if id == "" { | 180 | + | if id := os.Getenv("XDG_SESSION_ID"); id != "" { |
| 172 | - | return nil, fmt.Errorf("current process session unavailable: %w", err) | 181 | + | path, err := a.sessionByID(ctx, id) |
| 182 | + | if err == nil { | |||
| 183 | + | err = a.liveGraphical(ctx, path) | |||
| 184 | + | } | |||
| 185 | + | if err == nil { | |||
| 186 | + | return []any{path}, "XDG_SESSION_ID", nil | |||
| 187 | + | } | |||
| 188 | + | errs = append(errs, fmt.Errorf("XDG_SESSION_ID: %w", err)) | |||
| 189 | + | } | |||
| 190 | + | path, err := a.displaySession(ctx) | |||
| 191 | + | if err == nil { | |||
| 192 | + | err = a.liveGraphical(ctx, path) | |||
| 193 | + | } | |||
| 194 | + | if err == nil { | |||
| 195 | + | return []any{path}, "user display", nil | |||
| 196 | + | } | |||
| 197 | + | errs = append(errs, fmt.Errorf("user display session: %w", err)) | |||
| 198 | + | return nil, "", fmt.Errorf("current process session unavailable: %w", errors.Join(errs...)) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func (a *Adapter) sessionByID(ctx context.Context, id string) (dbus.ObjectPath, error) { | |||
| 202 | + | body, err := a.call(ctx, managerPath, managerInterface+".GetSession", id) | |||
| 203 | + | if err != nil { | |||
| 204 | + | return "", err | |||
| 205 | + | } | |||
| 206 | + | if len(body) != 1 { | |||
| 207 | + | return "", errors.New("invalid session reply") | |||
| 208 | + | } | |||
| 209 | + | path, ok := body[0].(dbus.ObjectPath) | |||
| 210 | + | if !ok || !path.IsValid() { | |||
| 211 | + | return "", errors.New("invalid session path") | |||
| 212 | + | } | |||
| 213 | + | return path, nil | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // liveGraphical rejects tty, closing and lingering sessions, so a fallback | |||
| 217 | + | // never binds lock signals to a session the user is not looking at. "online" | |||
| 218 | + | // (logged in, not foreground) is accepted so a daemon started while the user | |||
| 219 | + | // is on another VT still binds; with two graphical sessions of the same user | |||
| 220 | + | // a stale XDG_SESSION_ID may thus pick the background one (same UID only). | |||
| 221 | + | func (a *Adapter) liveGraphical(ctx context.Context, path dbus.ObjectPath) error { | |||
| 222 | + | value, err := a.property(ctx, path, sessionInterface, "Type") | |||
| 223 | + | if err != nil { | |||
| 224 | + | return fmt.Errorf("%s: %w", path, err) | |||
| 173 | } | 225 | } | ||
| 174 | - | body, idErr := a.call(ctx, managerPath, managerInterface+".GetSession", id) | 226 | + | if t, _ := value.(string); t != "wayland" && t != "x11" { |
| 175 | - | if idErr != nil { | 227 | + | return fmt.Errorf("%s: session type %q is not graphical", path, t) |
| 176 | - | return nil, fmt.Errorf("current process session unavailable: %w; XDG_SESSION_ID: %w", err, idErr) | |||
| 177 | } | 228 | } | ||
| 178 | - | return body, nil | 229 | + | value, err = a.property(ctx, path, sessionInterface, "State") |
| 230 | + | if err != nil { | |||
| 231 | + | return fmt.Errorf("%s: %w", path, err) | |||
| 232 | + | } | |||
| 233 | + | if s, _ := value.(string); s != "active" && s != "online" { | |||
| 234 | + | return fmt.Errorf("%s: session state %q is not live", path, s) | |||
| 235 | + | } | |||
| 236 | + | return nil | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // displaySession returns the real UID's primary (Display) session from | |||
| 240 | + | // logind. logind prefers a graphical one; liveGraphical enforces it. | |||
| 241 | + | func (a *Adapter) displaySession(ctx context.Context) (dbus.ObjectPath, error) { | |||
| 242 | + | body, err := a.call(ctx, managerPath, managerInterface+".GetUser", uint32(os.Getuid())) | |||
| 243 | + | if err != nil { | |||
| 244 | + | return "", err | |||
| 245 | + | } | |||
| 246 | + | if len(body) != 1 { | |||
| 247 | + | return "", errors.New("invalid user reply") | |||
| 248 | + | } | |||
| 249 | + | user, ok := body[0].(dbus.ObjectPath) | |||
| 250 | + | if !ok || !user.IsValid() { | |||
| 251 | + | return "", errors.New("invalid user path") | |||
| 252 | + | } | |||
| 253 | + | value, err := a.property(ctx, user, userInterface, "Display") | |||
| 254 | + | if err != nil { | |||
| 255 | + | return "", err | |||
| 256 | + | } | |||
| 257 | + | fields, ok := value.([]any) | |||
| 258 | + | if !ok || len(fields) != 2 { | |||
| 259 | + | return "", errors.New("invalid user Display property") | |||
| 260 | + | } | |||
| 261 | + | path, ok := fields[1].(dbus.ObjectPath) | |||
| 262 | + | if !ok || !path.IsValid() || path == "/" { | |||
| 263 | + | return "", errors.New("no display session") | |||
| 264 | + | } | |||
| 265 | + | return path, nil | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | // Session reports the bound session path and how it was found. | |||
| 269 | + | func (a *Adapter) Session() (path, source string) { | |||
| 270 | + | return string(a.session), a.sessionSource | |||
| 179 | } | 271 | } | ||
| 180 | 272 | ||||
| 181 | func (a *Adapter) Capabilities() ports.SystemCapabilities { | 273 | func (a *Adapter) Capabilities() ports.SystemCapabilities { | ||
M internal/adapters/logind/logind_test.go +138 -9
| @@ -37,36 +37,165 @@ func TestProbeCurrentPIDRealUIDAndReadOnly(t *testing.T) { | |||
| 37 | 37 | if c := a.Capabilities(); !c.Session || !c.Suspend || c.DelayMax != 5*time.Second { | |
| 38 | 38 | t.Fatalf("capabilities %+v", c) | |
| 39 | 39 | } | |
| 40 | + | if path, source := a.Session(); path != string(testSession) || source != "pid" { | |
| 41 | + | t.Fatalf("session %q source %q", path, source) | |
| 42 | + | } | |
| 40 | 43 | // No mutating call was configured: generated mock rejects Inhibit/Suspend. | |
| 41 | 44 | } | |
| 42 | 45 | ||
| 46 | + | const testUser dbus.ObjectPath = "/org/freedesktop/login1/user/_1000" | |
| 47 | + | const staleSession dbus.ObjectPath = "/org/freedesktop/login1/session/c9" | |
| 48 | + | ||
| 49 | + | var errPID, errXDG, errUser = errors.New("pid lookup"), errors.New("xdg lookup"), errors.New("user lookup") | |
| 50 | + | ||
| 51 | + | // expectNoPIDSession answers the owner lookup and fails the PID session. | |
| 52 | + | func expectNoPIDSession(b *mockbus) { | |
| 53 | + | b.EXPECT().call(mock.Anything, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once() | |
| 54 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return(nil, errPID).Once() | |
| 55 | + | } | |
| 56 | + | func expectSessionProperty(b *mockbus, path dbus.ObjectPath, name string, value any) { | |
| 57 | + | b.EXPECT().call(mock.Anything, testOwner, path, "org.freedesktop.DBus.Properties.Get", []any{sessionInterface, name}).Return([]any{dbus.MakeVariant(value)}, nil).Once() | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // expectSessionKind answers Type, then State only when Type is graphical. | |
| 61 | + | func expectSessionKind(b *mockbus, path dbus.ObjectPath, typ, state string) { | |
| 62 | + | expectSessionProperty(b, path, "Type", typ) | |
| 63 | + | if typ == "wayland" || typ == "x11" { | |
| 64 | + | expectSessionProperty(b, path, "State", state) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | func expectSessionUID(b *mockbus, path dbus.ObjectPath, uid uint32) { | |
| 68 | + | expectSessionProperty(b, path, "User", []any{uid, testUser}) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // expectDisplay answers the GetUser + User.Display fallback with display. | |
| 72 | + | func expectDisplay(b *mockbus, display any) { | |
| 73 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetUser", []any{uint32(os.Getuid())}).Return([]any{testUser}, nil).Once() | |
| 74 | + | b.EXPECT().call(mock.Anything, testOwner, testUser, "org.freedesktop.DBus.Properties.Get", []any{userInterface, "Display"}).Return([]any{dbus.MakeVariant(display)}, nil).Once() | |
| 75 | + | } | |
| 76 | + | ||
| 43 | 77 | // A compositor run as a systemd user service gives its children no PID | |
| 44 | - | // session; the display manager's XDG_SESSION_ID is used, UID-checked. | |
| 78 | + | // session; the display manager's live graphical XDG_SESSION_ID is used, | |
| 79 | + | // UID-checked. | |
| 45 | 80 | func TestProbeFallsBackToXDGSessionID(t *testing.T) { | |
| 46 | 81 | t.Setenv("XDG_SESSION_ID", "c1") | |
| 47 | 82 | for name, uid := range map[string]uint32{"same uid": uint32(os.Getuid()), "other uid": uint32(os.Getuid()) + 1} { | |
| 48 | 83 | t.Run(name, func(t *testing.T) { | |
| 49 | 84 | b := newMockbus(t) | |
| 50 | - | b.EXPECT().call(mock.Anything, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once() | |
| 51 | - | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return(nil, errors.New("no session")).Once() | |
| 85 | + | expectNoPIDSession(b) | |
| 52 | 86 | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{"c1"}).Return([]any{testSession}, nil).Once() | |
| 53 | - | b.EXPECT().call(mock.Anything, testOwner, testSession, "org.freedesktop.DBus.Properties.Get", []any{sessionInterface, "User"}).Return([]any{dbus.MakeVariant([]any{uid, dbus.ObjectPath("/org/freedesktop/login1/user/_1000")})}, nil).Once() | |
| 87 | + | expectSessionKind(b, testSession, "wayland", "active") | |
| 88 | + | expectSessionUID(b, testSession, uid) | |
| 54 | 89 | a := newAdapter(b, ports.SystemRequirements{Session: true}) | |
| 55 | 90 | err := a.Probe(context.Background()) | |
| 56 | 91 | if ok := uid == uint32(os.Getuid()); ok != (err == nil) || ok != a.Capabilities().Session || ok != (a.session == testSession) { | |
| 57 | 92 | t.Fatalf("err=%v caps=%+v session=%q", err, a.Capabilities(), a.session) | |
| 58 | 93 | } | |
| 94 | + | want := "" | |
| 95 | + | if uid == uint32(os.Getuid()) { | |
| 96 | + | want = "XDG_SESSION_ID" | |
| 97 | + | } | |
| 98 | + | if _, source := a.Session(); source != want { | |
| 99 | + | t.Fatalf("source %q, want %q", source, want) | |
| 100 | + | } | |
| 101 | + | }) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // A terminal started by the compositor can inherit a stale XDG_SESSION_ID and | |
| 106 | + | // run outside any session scope. An unknown, non-graphical or closing XDG | |
| 107 | + | // session falls through to the real UID's live graphical Display session. | |
| 108 | + | func TestProbeFallsBackToDisplaySession(t *testing.T) { | |
| 109 | + | cases := map[string]struct { | |
| 110 | + | xdg string | |
| 111 | + | xdgExists bool | |
| 112 | + | typ string | |
| 113 | + | state string | |
| 114 | + | }{ | |
| 115 | + | "no XDG_SESSION_ID": {}, | |
| 116 | + | "unknown XDG_SESSION_ID": {xdg: "c9"}, | |
| 117 | + | "tty XDG_SESSION_ID": {xdg: "c9", xdgExists: true, typ: "tty"}, | |
| 118 | + | "closing XDG_SESSION_ID": {xdg: "c9", xdgExists: true, typ: "wayland", state: "closing"}, | |
| 119 | + | } | |
| 120 | + | for name, tc := range cases { | |
| 121 | + | t.Run(name, func(t *testing.T) { | |
| 122 | + | t.Setenv("XDG_SESSION_ID", tc.xdg) | |
| 123 | + | b := newMockbus(t) | |
| 124 | + | expectNoPIDSession(b) | |
| 125 | + | switch { | |
| 126 | + | case tc.xdgExists: | |
| 127 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{tc.xdg}).Return([]any{staleSession}, nil).Once() | |
| 128 | + | expectSessionKind(b, staleSession, tc.typ, tc.state) | |
| 129 | + | case tc.xdg != "": | |
| 130 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{tc.xdg}).Return(nil, errXDG).Once() | |
| 131 | + | } | |
| 132 | + | expectDisplay(b, []any{"c1", testSession}) | |
| 133 | + | expectSessionKind(b, testSession, "wayland", "active") | |
| 134 | + | expectSessionUID(b, testSession, uint32(os.Getuid())) | |
| 135 | + | a := newAdapter(b, ports.SystemRequirements{Session: true}) | |
| 136 | + | if err := a.Probe(context.Background()); err != nil || a.session != testSession || !a.Capabilities().Session { | |
| 137 | + | t.Fatalf("err=%v session=%q", err, a.session) | |
| 138 | + | } | |
| 139 | + | if _, source := a.Session(); source != "user display" { | |
| 140 | + | t.Fatalf("source %q", source) | |
| 141 | + | } | |
| 59 | 142 | }) | |
| 60 | 143 | } | |
| 61 | 144 | } | |
| 62 | 145 | ||
| 63 | - | func TestProbeWithoutPIDSessionOrXDGSessionIDFails(t *testing.T) { | |
| 146 | + | func TestProbeRejectsDisplaySessionOfOtherUID(t *testing.T) { | |
| 64 | 147 | t.Setenv("XDG_SESSION_ID", "") | |
| 65 | 148 | b := newMockbus(t) | |
| 66 | - | b.EXPECT().call(mock.Anything, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once() | |
| 67 | - | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return(nil, errors.New("no session")).Once() | |
| 68 | - | if err := newAdapter(b, ports.SystemRequirements{Session: true}).Probe(context.Background()); err == nil { | |
| 69 | - | t.Fatal("probe succeeded without any session") | |
| 149 | + | expectNoPIDSession(b) | |
| 150 | + | expectDisplay(b, []any{"c1", testSession}) | |
| 151 | + | expectSessionKind(b, testSession, "wayland", "active") | |
| 152 | + | expectSessionUID(b, testSession, uint32(os.Getuid())+1) | |
| 153 | + | a := newAdapter(b, ports.SystemRequirements{Session: true}) | |
| 154 | + | if err := a.Probe(context.Background()); err == nil || a.Capabilities().Session { | |
| 155 | + | t.Fatal("other UID admitted through the Display fallback") | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Every source's cause stays inspectable through the joined error. | |
| 160 | + | func TestProbeWithoutAnySessionReportsEveryCause(t *testing.T) { | |
| 161 | + | t.Setenv("XDG_SESSION_ID", "c9") | |
| 162 | + | b := newMockbus(t) | |
| 163 | + | expectNoPIDSession(b) | |
| 164 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{"c9"}).Return(nil, errXDG).Once() | |
| 165 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetUser", []any{uint32(os.Getuid())}).Return(nil, errUser).Once() | |
| 166 | + | err := newAdapter(b, ports.SystemRequirements{Session: true}).Probe(context.Background()) | |
| 167 | + | for _, cause := range []error{errPID, errXDG, errUser} { | |
| 168 | + | if !errors.Is(err, cause) { | |
| 169 | + | t.Fatalf("err=%v lacks %v", err, cause) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestDisplaySessionRejectsMalformedReplies(t *testing.T) { | |
| 175 | + | cases := map[string]struct { | |
| 176 | + | user []any | |
| 177 | + | display any | |
| 178 | + | }{ | |
| 179 | + | "empty user reply": {user: []any{}}, | |
| 180 | + | "non-path user": {user: []any{"_1000"}}, | |
| 181 | + | "display not tuple": {user: []any{testUser}, display: "c1"}, | |
| 182 | + | "display short": {user: []any{testUser}, display: []any{"c1"}}, | |
| 183 | + | "display not a path": {user: []any{testUser}, display: []any{"c1", "c1"}}, | |
| 184 | + | "no display session": {user: []any{testUser}, display: []any{"", dbus.ObjectPath("/")}}, | |
| 185 | + | } | |
| 186 | + | for name, tc := range cases { | |
| 187 | + | t.Run(name, func(t *testing.T) { | |
| 188 | + | b := newMockbus(t) | |
| 189 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetUser", []any{uint32(os.Getuid())}).Return(tc.user, nil).Once() | |
| 190 | + | if tc.display != nil { | |
| 191 | + | b.EXPECT().call(mock.Anything, testOwner, testUser, "org.freedesktop.DBus.Properties.Get", []any{userInterface, "Display"}).Return([]any{dbus.MakeVariant(tc.display)}, nil).Once() | |
| 192 | + | } | |
| 193 | + | a := newAdapter(b, ports.SystemRequirements{Session: true}) | |
| 194 | + | a.owner = testOwner | |
| 195 | + | if path, err := a.displaySession(context.Background()); err == nil { | |
| 196 | + | t.Fatalf("accepted %q", path) | |
| 197 | + | } | |
| 198 | + | }) | |
| 70 | 199 | } | |
| 71 | 200 | } | |
| 72 | 201 | ||
| @@ -37,36 +37,165 @@ func TestProbeCurrentPIDRealUIDAndReadOnly(t *testing.T) { | |||||
| 37 | if c := a.Capabilities(); !c.Session || !c.Suspend || c.DelayMax != 5*time.Second { | 37 | if c := a.Capabilities(); !c.Session || !c.Suspend || c.DelayMax != 5*time.Second { | ||
| 38 | t.Fatalf("capabilities %+v", c) | 38 | t.Fatalf("capabilities %+v", c) | ||
| 39 | } | 39 | } | ||
| 40 | + | if path, source := a.Session(); path != string(testSession) || source != "pid" { | |||
| 41 | + | t.Fatalf("session %q source %q", path, source) | |||
| 42 | + | } | |||
| 40 | // No mutating call was configured: generated mock rejects Inhibit/Suspend. | 43 | // No mutating call was configured: generated mock rejects Inhibit/Suspend. | ||
| 41 | } | 44 | } | ||
| 42 | 45 | ||||
| 46 | + | const testUser dbus.ObjectPath = "/org/freedesktop/login1/user/_1000" | |||
| 47 | + | const staleSession dbus.ObjectPath = "/org/freedesktop/login1/session/c9" | |||
| 48 | + | ||||
| 49 | + | var errPID, errXDG, errUser = errors.New("pid lookup"), errors.New("xdg lookup"), errors.New("user lookup") | |||
| 50 | + | ||||
| 51 | + | // expectNoPIDSession answers the owner lookup and fails the PID session. | |||
| 52 | + | func expectNoPIDSession(b *mockbus) { | |||
| 53 | + | b.EXPECT().call(mock.Anything, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once() | |||
| 54 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return(nil, errPID).Once() | |||
| 55 | + | } | |||
| 56 | + | func expectSessionProperty(b *mockbus, path dbus.ObjectPath, name string, value any) { | |||
| 57 | + | b.EXPECT().call(mock.Anything, testOwner, path, "org.freedesktop.DBus.Properties.Get", []any{sessionInterface, name}).Return([]any{dbus.MakeVariant(value)}, nil).Once() | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // expectSessionKind answers Type, then State only when Type is graphical. | |||
| 61 | + | func expectSessionKind(b *mockbus, path dbus.ObjectPath, typ, state string) { | |||
| 62 | + | expectSessionProperty(b, path, "Type", typ) | |||
| 63 | + | if typ == "wayland" || typ == "x11" { | |||
| 64 | + | expectSessionProperty(b, path, "State", state) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | func expectSessionUID(b *mockbus, path dbus.ObjectPath, uid uint32) { | |||
| 68 | + | expectSessionProperty(b, path, "User", []any{uid, testUser}) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // expectDisplay answers the GetUser + User.Display fallback with display. | |||
| 72 | + | func expectDisplay(b *mockbus, display any) { | |||
| 73 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetUser", []any{uint32(os.Getuid())}).Return([]any{testUser}, nil).Once() | |||
| 74 | + | b.EXPECT().call(mock.Anything, testOwner, testUser, "org.freedesktop.DBus.Properties.Get", []any{userInterface, "Display"}).Return([]any{dbus.MakeVariant(display)}, nil).Once() | |||
| 75 | + | } | |||
| 76 | + | ||||
| 43 | // A compositor run as a systemd user service gives its children no PID | 77 | // A compositor run as a systemd user service gives its children no PID | ||
| 44 | - | // session; the display manager's XDG_SESSION_ID is used, UID-checked. | 78 | + | // session; the display manager's live graphical XDG_SESSION_ID is used, |
| 79 | + | // UID-checked. | |||
| 45 | func TestProbeFallsBackToXDGSessionID(t *testing.T) { | 80 | func TestProbeFallsBackToXDGSessionID(t *testing.T) { | ||
| 46 | t.Setenv("XDG_SESSION_ID", "c1") | 81 | t.Setenv("XDG_SESSION_ID", "c1") | ||
| 47 | for name, uid := range map[string]uint32{"same uid": uint32(os.Getuid()), "other uid": uint32(os.Getuid()) + 1} { | 82 | for name, uid := range map[string]uint32{"same uid": uint32(os.Getuid()), "other uid": uint32(os.Getuid()) + 1} { | ||
| 48 | t.Run(name, func(t *testing.T) { | 83 | t.Run(name, func(t *testing.T) { | ||
| 49 | b := newMockbus(t) | 84 | b := newMockbus(t) | ||
| 50 | - | b.EXPECT().call(mock.Anything, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once() | 85 | + | expectNoPIDSession(b) |
| 51 | - | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return(nil, errors.New("no session")).Once() | |||
| 52 | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{"c1"}).Return([]any{testSession}, nil).Once() | 86 | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{"c1"}).Return([]any{testSession}, nil).Once() | ||
| 53 | - | b.EXPECT().call(mock.Anything, testOwner, testSession, "org.freedesktop.DBus.Properties.Get", []any{sessionInterface, "User"}).Return([]any{dbus.MakeVariant([]any{uid, dbus.ObjectPath("/org/freedesktop/login1/user/_1000")})}, nil).Once() | 87 | + | expectSessionKind(b, testSession, "wayland", "active") |
| 88 | + | expectSessionUID(b, testSession, uid) | |||
| 54 | a := newAdapter(b, ports.SystemRequirements{Session: true}) | 89 | a := newAdapter(b, ports.SystemRequirements{Session: true}) | ||
| 55 | err := a.Probe(context.Background()) | 90 | err := a.Probe(context.Background()) | ||
| 56 | if ok := uid == uint32(os.Getuid()); ok != (err == nil) || ok != a.Capabilities().Session || ok != (a.session == testSession) { | 91 | if ok := uid == uint32(os.Getuid()); ok != (err == nil) || ok != a.Capabilities().Session || ok != (a.session == testSession) { | ||
| 57 | t.Fatalf("err=%v caps=%+v session=%q", err, a.Capabilities(), a.session) | 92 | t.Fatalf("err=%v caps=%+v session=%q", err, a.Capabilities(), a.session) | ||
| 58 | } | 93 | } | ||
| 94 | + | want := "" | |||
| 95 | + | if uid == uint32(os.Getuid()) { | |||
| 96 | + | want = "XDG_SESSION_ID" | |||
| 97 | + | } | |||
| 98 | + | if _, source := a.Session(); source != want { | |||
| 99 | + | t.Fatalf("source %q, want %q", source, want) | |||
| 100 | + | } | |||
| 101 | + | }) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // A terminal started by the compositor can inherit a stale XDG_SESSION_ID and | |||
| 106 | + | // run outside any session scope. An unknown, non-graphical or closing XDG | |||
| 107 | + | // session falls through to the real UID's live graphical Display session. | |||
| 108 | + | func TestProbeFallsBackToDisplaySession(t *testing.T) { | |||
| 109 | + | cases := map[string]struct { | |||
| 110 | + | xdg string | |||
| 111 | + | xdgExists bool | |||
| 112 | + | typ string | |||
| 113 | + | state string | |||
| 114 | + | }{ | |||
| 115 | + | "no XDG_SESSION_ID": {}, | |||
| 116 | + | "unknown XDG_SESSION_ID": {xdg: "c9"}, | |||
| 117 | + | "tty XDG_SESSION_ID": {xdg: "c9", xdgExists: true, typ: "tty"}, | |||
| 118 | + | "closing XDG_SESSION_ID": {xdg: "c9", xdgExists: true, typ: "wayland", state: "closing"}, | |||
| 119 | + | } | |||
| 120 | + | for name, tc := range cases { | |||
| 121 | + | t.Run(name, func(t *testing.T) { | |||
| 122 | + | t.Setenv("XDG_SESSION_ID", tc.xdg) | |||
| 123 | + | b := newMockbus(t) | |||
| 124 | + | expectNoPIDSession(b) | |||
| 125 | + | switch { | |||
| 126 | + | case tc.xdgExists: | |||
| 127 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{tc.xdg}).Return([]any{staleSession}, nil).Once() | |||
| 128 | + | expectSessionKind(b, staleSession, tc.typ, tc.state) | |||
| 129 | + | case tc.xdg != "": | |||
| 130 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{tc.xdg}).Return(nil, errXDG).Once() | |||
| 131 | + | } | |||
| 132 | + | expectDisplay(b, []any{"c1", testSession}) | |||
| 133 | + | expectSessionKind(b, testSession, "wayland", "active") | |||
| 134 | + | expectSessionUID(b, testSession, uint32(os.Getuid())) | |||
| 135 | + | a := newAdapter(b, ports.SystemRequirements{Session: true}) | |||
| 136 | + | if err := a.Probe(context.Background()); err != nil || a.session != testSession || !a.Capabilities().Session { | |||
| 137 | + | t.Fatalf("err=%v session=%q", err, a.session) | |||
| 138 | + | } | |||
| 139 | + | if _, source := a.Session(); source != "user display" { | |||
| 140 | + | t.Fatalf("source %q", source) | |||
| 141 | + | } | |||
| 59 | }) | 142 | }) | ||
| 60 | } | 143 | } | ||
| 61 | } | 144 | } | ||
| 62 | 145 | ||||
| 63 | - | func TestProbeWithoutPIDSessionOrXDGSessionIDFails(t *testing.T) { | 146 | + | func TestProbeRejectsDisplaySessionOfOtherUID(t *testing.T) { |
| 64 | t.Setenv("XDG_SESSION_ID", "") | 147 | t.Setenv("XDG_SESSION_ID", "") | ||
| 65 | b := newMockbus(t) | 148 | b := newMockbus(t) | ||
| 66 | - | b.EXPECT().call(mock.Anything, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once() | 149 | + | expectNoPIDSession(b) |
| 67 | - | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return(nil, errors.New("no session")).Once() | 150 | + | expectDisplay(b, []any{"c1", testSession}) |
| 68 | - | if err := newAdapter(b, ports.SystemRequirements{Session: true}).Probe(context.Background()); err == nil { | 151 | + | expectSessionKind(b, testSession, "wayland", "active") |
| 69 | - | t.Fatal("probe succeeded without any session") | 152 | + | expectSessionUID(b, testSession, uint32(os.Getuid())+1) |
| 153 | + | a := newAdapter(b, ports.SystemRequirements{Session: true}) | |||
| 154 | + | if err := a.Probe(context.Background()); err == nil || a.Capabilities().Session { | |||
| 155 | + | t.Fatal("other UID admitted through the Display fallback") | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Every source's cause stays inspectable through the joined error. | |||
| 160 | + | func TestProbeWithoutAnySessionReportsEveryCause(t *testing.T) { | |||
| 161 | + | t.Setenv("XDG_SESSION_ID", "c9") | |||
| 162 | + | b := newMockbus(t) | |||
| 163 | + | expectNoPIDSession(b) | |||
| 164 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{"c9"}).Return(nil, errXDG).Once() | |||
| 165 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetUser", []any{uint32(os.Getuid())}).Return(nil, errUser).Once() | |||
| 166 | + | err := newAdapter(b, ports.SystemRequirements{Session: true}).Probe(context.Background()) | |||
| 167 | + | for _, cause := range []error{errPID, errXDG, errUser} { | |||
| 168 | + | if !errors.Is(err, cause) { | |||
| 169 | + | t.Fatalf("err=%v lacks %v", err, cause) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestDisplaySessionRejectsMalformedReplies(t *testing.T) { | |||
| 175 | + | cases := map[string]struct { | |||
| 176 | + | user []any | |||
| 177 | + | display any | |||
| 178 | + | }{ | |||
| 179 | + | "empty user reply": {user: []any{}}, | |||
| 180 | + | "non-path user": {user: []any{"_1000"}}, | |||
| 181 | + | "display not tuple": {user: []any{testUser}, display: "c1"}, | |||
| 182 | + | "display short": {user: []any{testUser}, display: []any{"c1"}}, | |||
| 183 | + | "display not a path": {user: []any{testUser}, display: []any{"c1", "c1"}}, | |||
| 184 | + | "no display session": {user: []any{testUser}, display: []any{"", dbus.ObjectPath("/")}}, | |||
| 185 | + | } | |||
| 186 | + | for name, tc := range cases { | |||
| 187 | + | t.Run(name, func(t *testing.T) { | |||
| 188 | + | b := newMockbus(t) | |||
| 189 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetUser", []any{uint32(os.Getuid())}).Return(tc.user, nil).Once() | |||
| 190 | + | if tc.display != nil { | |||
| 191 | + | b.EXPECT().call(mock.Anything, testOwner, testUser, "org.freedesktop.DBus.Properties.Get", []any{userInterface, "Display"}).Return([]any{dbus.MakeVariant(tc.display)}, nil).Once() | |||
| 192 | + | } | |||
| 193 | + | a := newAdapter(b, ports.SystemRequirements{Session: true}) | |||
| 194 | + | a.owner = testOwner | |||
| 195 | + | if path, err := a.displaySession(context.Background()); err == nil { | |||
| 196 | + | t.Fatalf("accepted %q", path) | |||
| 197 | + | } | |||
| 198 | + | }) | |||
| 70 | } | 199 | } | ||
| 71 | } | 200 | } | ||
| 72 | 201 | ||||
M internal/app/daemon.go +3 -0
| @@ -77,6 +77,9 @@ func RunDaemon(ctx context.Context, o DaemonOptions) (result error) { | |||
| 77 | 77 | sys = nil | |
| 78 | 78 | return &UnsupportedError{Missing: []string{"logind: " + err.Error()}} | |
| 79 | 79 | } | |
| 80 | + | if path, source := sys.Session(); path != "" { | |
| 81 | + | log.Info().Str("session", path).Str("source", source).Msg("logind session bound") | |
| 82 | + | } | |
| 80 | 83 | ||
| 81 | 84 | uid := uint32(os.Getuid()) | |
| 82 | 85 | sockPath, err := control.DefaultPath() | |
| @@ -77,6 +77,9 @@ func RunDaemon(ctx context.Context, o DaemonOptions) (result error) { | |||||
| 77 | sys = nil | 77 | sys = nil | ||
| 78 | return &UnsupportedError{Missing: []string{"logind: " + err.Error()}} | 78 | return &UnsupportedError{Missing: []string{"logind: " + err.Error()}} | ||
| 79 | } | 79 | } | ||
| 80 | + | if path, source := sys.Session(); path != "" { | |||
| 81 | + | log.Info().Str("session", path).Str("source", source).Msg("logind session bound") | |||
| 82 | + | } | |||
| 80 | 83 | ||||
| 81 | uid := uint32(os.Getuid()) | 84 | uid := uint32(os.Getuid()) | ||
| 82 | sockPath, err := control.DefaultPath() | 85 | sockPath, err := control.DefaultPath() | ||