detached a514042

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 UnverifiedB5690EEEBB952194 The 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"
2020 const managerPath dbus.ObjectPath = "/org/freedesktop/login1"
2121 const managerInterface = "org.freedesktop.login1.Manager"
2222 const sessionInterface = "org.freedesktop.login1.Session"
23+const userInterface = "org.freedesktop.login1.User"
2324 const callTimeout = 3 * time.Second
2425
2526 // Adapter is single-owner: Probe, Run and Close must not execute concurrently.
@@ -29,12 +30,14 @@ type Adapter struct {
2930 requirements ports.SystemRequirements
3031 owner string
3132 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
3841 }
3942
4043 // 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
7982 }
8083
8184 // 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.
8387 func (a *Adapter) Probe(ctx context.Context) error {
8488 bounded, cancel := context.WithTimeout(ctx, callTimeout)
8589 defer cancel()
@@ -96,8 +100,10 @@ func (a *Adapter) Probe(ctx context.Context) error {
96100 }
97101 a.owner = owner
98102 a.caps = ports.SystemCapabilities{}
103+ a.session, a.sessionSource = "", ""
99104 if a.requirements.Session {
100- body, err = a.currentSession(ctx)
105+ var source string
106+ body, source, err = a.currentSession(ctx)
101107 if err != nil {
102108 return err
103109 }
@@ -120,7 +126,7 @@ func (a *Adapter) Probe(ctx context.Context) error {
120126 if !ok || uid != uint32(os.Getuid()) {
121127 return errors.New("session does not belong to real UID")
122128 }
123- a.session = path
129+ a.session, a.sessionSource = path, source
124130 a.caps.Session = true
125131 }
126132 if a.requirements.Sleep {
@@ -161,21 +167,107 @@ func (a *Adapter) Probe(ctx context.Context) error {
161167 // currentSession resolves the process's session by PID. A compositor started
162168 // as a systemd user service runs outside the session scope, so its children
163169 // 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) {
166175 body, err := a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid()))
167176 if err == nil {
168- return body, nil
177+ return body, "pid", nil
169178 }
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)
173225 }
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)
177228 }
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
179271 }
180272
181273 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.Second24 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.SystemRequirements30 requirements ports.SystemRequirements
30 owner string31 owner string
31 session dbus.ObjectPath32 session dbus.ObjectPath
32- caps ports.SystemCapabilities33+ // sessionSource names how session was found, for diagnostics.
33- signals chan *dbus.Signal34+ sessionSource string
34- inhibitor *os.File35+ caps ports.SystemCapabilities
35- cycle uint6436+ signals chan *dbus.Signal
36- preparing bool37+ inhibitor *os.File
37- closed bool38+ cycle uint64
39+ preparing bool
40+ closed bool
38 }41 }
39 42
40 // Open connects only when requested. Calling it is unnecessary when all system43 // 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 the84 // 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 = owner101 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 err108 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 = path129+ a.session, a.sessionSource = path, source
124 a.caps.Session = true130 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 started167 // currentSession resolves the process's session by PID. A compositor started
162 // as a systemd user service runs outside the session scope, so its children168 // 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 taken169 // 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, nil177+ 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, nil229+ 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) {
3737 if c := a.Capabilities(); !c.Session || !c.Suspend || c.DelayMax != 5*time.Second {
3838 t.Fatalf("capabilities %+v", c)
3939 }
40+ if path, source := a.Session(); path != string(testSession) || source != "pid" {
41+ t.Fatalf("session %q source %q", path, source)
42+ }
4043 // No mutating call was configured: generated mock rejects Inhibit/Suspend.
4144 }
4245
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+
4377 // 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.
4580 func TestProbeFallsBackToXDGSessionID(t *testing.T) {
4681 t.Setenv("XDG_SESSION_ID", "c1")
4782 for name, uid := range map[string]uint32{"same uid": uint32(os.Getuid()), "other uid": uint32(os.Getuid()) + 1} {
4883 t.Run(name, func(t *testing.T) {
4984 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)
5286 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)
5489 a := newAdapter(b, ports.SystemRequirements{Session: true})
5590 err := a.Probe(context.Background())
5691 if ok := uid == uint32(os.Getuid()); ok != (err == nil) || ok != a.Capabilities().Session || ok != (a.session == testSession) {
5792 t.Fatalf("err=%v caps=%+v session=%q", err, a.Capabilities(), a.session)
5893 }
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+ }
59142 })
60143 }
61144 }
62145
63-func TestProbeWithoutPIDSessionOrXDGSessionIDFails(t *testing.T) {
146+func TestProbeRejectsDisplaySessionOfOtherUID(t *testing.T) {
64147 t.Setenv("XDG_SESSION_ID", "")
65148 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+ })
70199 }
71200 }
72201
@@ -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 PID77 // 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) {
7777 sys = nil
7878 return &UnsupportedError{Missing: []string{"logind: " + err.Error()}}
7979 }
80+ if path, source := sys.Session(); path != "" {
81+ log.Info().Str("session", path).Str("source", source).Msg("logind session bound")
82+ }
8083
8184 uid := uint32(os.Getuid())
8285 sockPath, err := control.DefaultPath()
@@ -77,6 +77,9 @@ func RunDaemon(ctx context.Context, o DaemonOptions) (result error) {
77 sys = nil77 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()

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 2

Tags 9

↑↓ moveenter openesc close