1package logind
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "testing"
9 "time"
10
11 "github.com/bnema/neferafk/internal/ports"
12 "github.com/godbus/dbus/v5"
13 "github.com/stretchr/testify/mock"
14 "golang.org/x/sys/unix"
15)
16
17const testOwner = ":1.42"
18const testSession dbus.ObjectPath = "/org/freedesktop/login1/session/c1"
19
20func expectProbe(t *testing.T, b *mockbus, uid uint32) {
21 t.Helper()
22 bounded := mock.MatchedBy(func(ctx context.Context) bool { d, ok := ctx.Deadline(); return ok && time.Until(d) <= callTimeout })
23 b.EXPECT().call(bounded, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once()
24 b.EXPECT().call(bounded, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return([]any{testSession}, nil).Once()
25 b.EXPECT().call(bounded, 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()
26}
27func TestProbeCurrentPIDRealUIDAndReadOnly(t *testing.T) {
28 b := newMockbus(t)
29 expectProbe(t, b, uint32(os.Getuid()))
30 b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".CanSuspend").Return([]any{"yes"}, nil).Once()
31 b.EXPECT().call(mock.Anything, testOwner, managerPath, "org.freedesktop.DBus.Properties.Get", []any{managerInterface, "InhibitDelayMaxUSec"}).Return([]any{dbus.MakeVariant(uint64(5_000_000))}, nil).Once()
32 b.EXPECT().unixFDs().Return(true).Once()
33 a := newAdapter(b, ports.SystemRequirements{Session: true, Sleep: true})
34 if err := a.Probe(context.Background()); err != nil {
35 t.Fatal(err)
36 }
37 if c := a.Capabilities(); !c.Session || !c.Suspend || c.DelayMax != 5*time.Second {
38 t.Fatalf("capabilities %+v", c)
39 }
40 if path, source := a.Session(); path != string(testSession) || source != "pid" {
41 t.Fatalf("session %q source %q", path, source)
42 }
43 // No mutating call was configured: generated mock rejects Inhibit/Suspend.
44}
45
46const testUser dbus.ObjectPath = "/org/freedesktop/login1/user/_1000"
47const staleSession dbus.ObjectPath = "/org/freedesktop/login1/session/c9"
48
49var 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.
52func 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}
56func 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.
61func 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}
67func 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.
72func 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
77// A compositor run as a systemd user service gives its children no PID
78// session; the display manager's live graphical XDG_SESSION_ID is used,
79// UID-checked.
80func TestProbeFallsBackToXDGSessionID(t *testing.T) {
81 t.Setenv("XDG_SESSION_ID", "c1")
82 for name, uid := range map[string]uint32{"same uid": uint32(os.Getuid()), "other uid": uint32(os.Getuid()) + 1} {
83 t.Run(name, func(t *testing.T) {
84 b := newMockbus(t)
85 expectNoPIDSession(b)
86 b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{"c1"}).Return([]any{testSession}, nil).Once()
87 expectSessionKind(b, testSession, "wayland", "active")
88 expectSessionUID(b, testSession, uid)
89 a := newAdapter(b, ports.SystemRequirements{Session: true})
90 err := a.Probe(context.Background())
91 if ok := uid == uint32(os.Getuid()); ok != (err == nil) || ok != a.Capabilities().Session || ok != (a.session == testSession) {
92 t.Fatalf("err=%v caps=%+v session=%q", err, a.Capabilities(), a.session)
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.
108func 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 }
142 })
143 }
144}
145
146func TestProbeRejectsDisplaySessionOfOtherUID(t *testing.T) {
147 t.Setenv("XDG_SESSION_ID", "")
148 b := newMockbus(t)
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.
160func 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
174func 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 })
199 }
200}
201
202func TestProbeRejectsOtherUID(t *testing.T) {
203 b := newMockbus(t)
204 expectProbe(t, b, uint32(os.Getuid())+1)
205 if err := newAdapter(b, ports.SystemRequirements{Session: true}).Probe(context.Background()); err == nil {
206 t.Fatal("other UID admitted")
207 }
208}
209func TestSignalFilteringAndUnlockNotAuthority(t *testing.T) {
210 cases := []struct {
211 sig *dbus.Signal
212 valid bool
213 }{
214 {&dbus.Signal{Sender: testOwner, Path: testSession, Name: sessionInterface + ".Lock"}, true},
215 {&dbus.Signal{Sender: testOwner, Path: testSession, Name: sessionInterface + ".Unlock"}, true},
216 {&dbus.Signal{Sender: testOwner, Path: managerPath, Name: managerInterface + ".PrepareForSleep", Body: []any{true}}, true},
217 {&dbus.Signal{Sender: ":1.99", Path: testSession, Name: sessionInterface + ".Lock"}, false},
218 {&dbus.Signal{Sender: testOwner, Path: "/org/freedesktop/login1/session/other", Name: sessionInterface + ".Unlock"}, false},
219 {&dbus.Signal{Sender: testOwner, Path: managerPath, Name: managerInterface + ".PrepareForSleep", Body: []any{"true"}}, false},
220 {&dbus.Signal{Sender: testOwner, Path: testSession, Name: sessionInterface + ".Lock", Body: []any{true}}, false},
221 }
222 for _, tc := range cases {
223 event, ok := parseSignal(tc.sig, testOwner, testSession)
224 if ok != tc.valid {
225 t.Fatalf("signal %+v accepted=%v", tc.sig, ok)
226 }
227 if tc.sig.Name == sessionInterface+".Unlock" && ok {
228 if _, ok := event.(ports.SystemUnlockObserved); !ok {
229 t.Fatalf("unlock converted to authority %T", event)
230 }
231 }
232 }
233}
234func TestInhibitorOwnsCloexecDuplicateAndClosesOnce(t *testing.T) {
235 b := newMockbus(t)
236 r, w, err := os.Pipe()
237 if err != nil {
238 t.Fatal(err)
239 }
240 defer r.Close()
241 defer w.Close()
242 received, err := unix.Dup(int(r.Fd()))
243 if err != nil {
244 t.Fatal(err)
245 }
246 b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".Inhibit", []any{"sleep", "neferafk", "session protection", "delay"}).Return([]any{dbus.UnixFD(received)}, nil).Once()
247 b.EXPECT().close().Return(nil).Once()
248 a := newAdapter(b, ports.SystemRequirements{Sleep: true})
249 a.owner = testOwner
250 if err := a.inhibit(context.Background()); err != nil {
251 t.Fatal(err)
252 }
253 if _, err := unix.FcntlInt(uintptr(received), unix.F_GETFD, 0); !errors.Is(err, unix.EBADF) {
254 t.Fatalf("received FD not closed: %v", err)
255 }
256 held := a.inhibitor
257 flags, err := unix.FcntlInt(held.Fd(), unix.F_GETFD, 0)
258 if err != nil || flags&unix.FD_CLOEXEC == 0 {
259 t.Fatal("inhibitor not CLOEXEC")
260 }
261 a.release()
262 a.release()
263 if _, err := held.Stat(); !errors.Is(err, os.ErrClosed) {
264 t.Fatal("owned inhibitor still open")
265 }
266 if err := a.Close(); err != nil {
267 t.Fatal(err)
268 }
269 if err := a.Close(); err != nil {
270 t.Fatal(err)
271 }
272}
273func TestRunMatchingSleepCycleAndSuspendFalse(t *testing.T) {
274 b := newMockbus(t)
275 a := newAdapter(b, ports.SystemRequirements{Session: true, Sleep: true})
276 a.owner = testOwner
277 a.session = testSession
278 a.caps = ports.SystemCapabilities{Session: true, Suspend: true, DelayMax: 5 * time.Second}
279 b.EXPECT().signals(mock.Anything).Return().Once()
280 b.EXPECT().removeSignals(mock.Anything).Return().Once()
281 b.EXPECT().match(mock.Anything, mock.Anything).Return(nil).Twice()
282 b.EXPECT().unixFDs().Return(true).Once()
283 b.EXPECT().close().Return(nil).Once()
284 r, w, err := os.Pipe()
285 if err != nil {
286 t.Fatal(err)
287 }
288 defer r.Close()
289 defer w.Close()
290 received, err := unix.Dup(int(r.Fd()))
291 if err != nil {
292 t.Fatal(err)
293 }
294 b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".Inhibit", []any{"sleep", "neferafk", "session protection", "delay"}).Return([]any{dbus.UnixFD(received)}, nil).Once()
295 suspended := make(chan struct{})
296 b.EXPECT().call(mock.Anything, testOwner, managerPath, "org.freedesktop.DBus.Properties.Get", []any{managerInterface, "BlockInhibited"}).Return([]any{dbus.MakeVariant("")}, nil).Once()
297 b.EXPECT().suspend(mock.Anything, testOwner).RunAndReturn(func(ctx context.Context, _ string) <-chan *dbus.Call {
298 if _, ok := ctx.Deadline(); !ok {
299 t.Error("unbounded suspend call")
300 }
301 close(suspended)
302 return make(chan *dbus.Call, 1) // reply held: owner must still process SleepReady
303 }).Once()
304 commands := make(chan ports.SystemCommand)
305 events := make(chan ports.SystemEvent, 8)
306 done := make(chan error, 1)
307 go func() { done <- a.Run(context.Background(), commands, events) }()
308 <-events // capability report synchronizes initial inhibitor ownership
309 a.signals <- &dbus.Signal{Sender: testOwner, Path: managerPath, Name: managerInterface + ".PrepareForSleep", Body: []any{true}}
310 prep := (<-events).(ports.SleepPreparation)
311 if prep.Cycle != 1 || prep.MaxDelay != 5*time.Second || prep.ReceivedAt.IsZero() {
312 t.Fatalf("sleep prep %+v", prep)
313 }
314 commands <- ports.SystemSleepReady{Cycle: 99}
315 commands <- ports.SystemSuspend{}
316 <-suspended // ordered owner checkpoint, no live suspend involved
317 held := a.inhibitor
318 if held == nil {
319 t.Fatal("stale cycle released inhibitor")
320 }
321 commands <- ports.SystemSleepReady{Cycle: prep.Cycle}
322 close(commands)
323 select {
324 case err := <-done:
325 if err != nil {
326 t.Fatal(err)
327 }
328 case <-time.After(time.Second):
329 t.Fatal("Run blocked")
330 }
331 if _, err := held.Stat(); !errors.Is(err, os.ErrClosed) {
332 t.Fatal("matching ready did not close inhibitor")
333 }
334}
335func TestOpenOffDoesNotConnect(t *testing.T) {
336 if _, err := Open(context.Background(), ports.SystemRequirements{}); err == nil {
337 t.Fatal("disabled adapter unexpectedly opened")
338 }
339}
340
341func TestSuspendBlockedAndUnavailableNeverCallsSuspend(t *testing.T) {
342 for _, blocked := range []bool{true, false} {
343 t.Run(fmt.Sprint(blocked), func(t *testing.T) {
344 b := newMockbus(t)
345 a := newAdapter(b, ports.SystemRequirements{Sleep: true})
346 a.owner = testOwner
347 a.caps.Suspend = blocked
348 b.EXPECT().signals(mock.Anything).Return().Once()
349 b.EXPECT().removeSignals(mock.Anything).Return().Once()
350 b.EXPECT().match(mock.Anything, mock.Anything).Return(nil).Once()
351 b.EXPECT().unixFDs().Return(false).Once()
352 b.EXPECT().close().Return(nil).Once()
353 if blocked {
354 b.EXPECT().call(mock.Anything, testOwner, managerPath, "org.freedesktop.DBus.Properties.Get", []any{managerInterface, "BlockInhibited"}).Return([]any{dbus.MakeVariant("sleep:shutdown")}, nil).Once()
355 }
356 commands := make(chan ports.SystemCommand, 1)
357 events := make(chan ports.SystemEvent, 3)
358 commands <- ports.SystemSuspend{}
359 close(commands)
360 if err := a.Run(context.Background(), commands, events); err != nil {
361 t.Fatal(err)
362 }
363 <-events
364 if failure, ok := (<-events).(ports.SystemFailure); !ok || failure.Operation != "suspend" {
365 t.Fatal("missing unsupported/block failure")
366 }
367 })
368 }
369}