1package powerkey
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "io"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "github.com/bnema/zerowrap"
17 "github.com/godbus/dbus/v5"
18)
19
20// startBus starts a dbus-daemon of its own, so tests never touch the
21// system bus. CI must run these tests: there, a missing daemon fails.
22func startBus(t *testing.T) string {
23 t.Helper()
24 bin, err := exec.LookPath("dbus-daemon")
25 if err != nil {
26 if os.Getenv("CI") != "" {
27 t.Fatal("dbus-daemon not installed")
28 }
29 t.Skip("dbus-daemon not installed")
30 }
31 socket := filepath.Join(t.TempDir(), "bus")
32 cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1", "--address=unix:path="+socket)
33 out, err := cmd.StdoutPipe()
34 if err != nil {
35 t.Fatal(err)
36 }
37 if err := cmd.Start(); err != nil {
38 t.Fatal(err)
39 }
40 t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
41 line, err := bufio.NewReader(out).ReadString('\n')
42 if err != nil {
43 t.Fatal(err)
44 }
45 return strings.TrimSpace(line)
46}
47
48// grant is one Inhibit call logind answered.
49type grant struct {
50 what, mode string
51 hold *os.File // our end of the lock: the client has its own copy
52 released *os.File // reads EOF once every copy of the lock is closed
53}
54
55// logindPeer plays logind on the private bus: it owns the name and answers
56// Inhibit with a pipe, whose other end shows when the client lets go.
57type logindPeer struct {
58 conn *dbus.Conn
59 grants chan grant
60}
61
62func newPeer(t *testing.T, address string) *logindPeer {
63 t.Helper()
64 conn, err := dbus.Connect(address)
65 if err != nil {
66 t.Fatal(err)
67 }
68 t.Cleanup(func() { _ = conn.Close() })
69 p := &logindPeer{conn: conn, grants: make(chan grant, 8)}
70 table := map[string]any{"Inhibit": func(what, who, why, mode string) (dbus.UnixFD, *dbus.Error) {
71 r, w, err := os.Pipe()
72 if err != nil {
73 return 0, dbus.MakeFailedError(err)
74 }
75 t.Cleanup(func() { _ = r.Close(); _ = w.Close() })
76 fd := dbus.UnixFD(w.Fd()) // before the send: the test closes w after it
77 p.grants <- grant{what: what, mode: mode, hold: w, released: r}
78 return fd, nil
79 }}
80 if err := conn.ExportMethodTable(table, logindPath, manager); err != nil {
81 t.Fatal(err)
82 }
83 p.own(t)
84 return p
85}
86
87func (p *logindPeer) own(t *testing.T) {
88 t.Helper()
89 if r, err := p.conn.RequestName(logind, dbus.NameFlagDoNotQueue); err != nil || r != dbus.RequestNameReplyPrimaryOwner {
90 t.Fatal(r, err)
91 }
92}
93
94func (p *logindPeer) next(t *testing.T) grant {
95 t.Helper()
96 select {
97 case g := <-p.grants:
98 return g
99 case <-time.After(5 * time.Second):
100 t.Fatal("no Inhibit call")
101 return grant{}
102 }
103}
104
105// logBuf is a log sink the test reads while Serve writes.
106type logBuf struct {
107 mu sync.Mutex
108 buf bytes.Buffer
109}
110
111func (l *logBuf) Write(p []byte) (int, error) {
112 l.mu.Lock()
113 defer l.mu.Unlock()
114 return l.buf.Write(p)
115}
116
117func (l *logBuf) count(s string) int {
118 l.mu.Lock()
119 defer l.mu.Unlock()
120 return strings.Count(l.buf.String(), s)
121}
122
123// wait blocks until the log has n lines containing msg.
124func (l *logBuf) wait(t *testing.T, msg string, n int) {
125 t.Helper()
126 for deadline := time.Now().Add(5 * time.Second); l.count(msg) < n; time.Sleep(5 * time.Millisecond) {
127 if time.Now().After(deadline) {
128 t.Fatalf("log never had %d x %q", n, msg)
129 }
130 }
131}
132
133// holding waits until Serve has received n lock answers: only then does it
134// own the descriptor.
135func (l *logBuf) holding(t *testing.T, n int) {
136 t.Helper()
137 l.wait(t, "holding logind key locks", n)
138}
139
140func run(t *testing.T, address string) (stop func(), log *logBuf) {
141 t.Helper()
142 ctx, cancel := context.WithCancel(context.Background())
143 done := make(chan struct{})
144 log = &logBuf{}
145 logger := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: log}).WithField("component", "powerkey")
146 go func() {
147 defer close(done)
148 Serve(ctx, address, 10*time.Millisecond, logger)
149 }()
150 var once bool
151 stop = func() {
152 if once {
153 return
154 }
155 once = true
156 cancel()
157 select {
158 case <-done:
159 case <-time.After(5 * time.Second):
160 t.Error("Serve outlived its context")
161 }
162 }
163 t.Cleanup(stop)
164 return stop, log
165}
166
167// released reports whether every copy of the lock is closed, once the
168// peer's own copy is.
169func released(t *testing.T, g grant) bool {
170 t.Helper()
171 _ = g.hold.Close()
172 _ = g.released.SetReadDeadline(time.Now().Add(2 * time.Second))
173 _, err := io.ReadAll(g.released)
174 return err == nil
175}
176
177// Serve blocks the four key handlers and lets go of them when it stops.
178func TestServeHoldsAndReleasesTheLock(t *testing.T) {
179 address := startBus(t)
180 peer := newPeer(t, address)
181 stop, log := run(t, address)
182 g := peer.next(t)
183 log.holding(t, 1)
184 if g.what != what || g.mode != "block" {
185 t.Fatalf("Inhibit(%q, %q)", g.what, g.mode)
186 }
187 stop()
188 if !released(t, g) {
189 t.Fatal("the lock outlived Serve")
190 }
191}
192
193// When logind restarts, the locks are taken again and the old ones closed.
194func TestServeTakesTheLockAgainAfterLogindRestarts(t *testing.T) {
195 address := startBus(t)
196 peer := newPeer(t, address)
197 _, log := run(t, address)
198 first := peer.next(t)
199 log.holding(t, 1)
200 if _, err := peer.conn.ReleaseName(logind); err != nil {
201 t.Fatal(err)
202 }
203 log.wait(t, "ignored until the next attempt", 1)
204 // The gap: the old lock is still ours, logind keeps it through a restart.
205 peer.own(t)
206 peer.next(t)
207 log.holding(t, 2)
208 if !released(t, first) {
209 t.Fatal("the first lock was not closed once the second was taken")
210 }
211}
212
213// Without logind on the bus, Serve keeps trying until it appears.
214func TestServeWaitsForLogind(t *testing.T) {
215 address := startBus(t)
216 peer := newPeer(t, address)
217 if _, err := peer.conn.ReleaseName(logind); err != nil {
218 t.Fatal(err)
219 }
220 _, log := run(t, address)
221 log.wait(t, "ignored until the next attempt", 1)
222 peer.own(t)
223 peer.next(t)
224 log.holding(t, 1)
225}
226
227func TestLostTrustsOnlyTheBus(t *testing.T) {
228 body := []any{logind, "", ""}
229 for name, tc := range map[string]struct {
230 sig *dbus.Signal
231 want bool
232 }{
233 "bus, logind gone": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: body}, true},
234 "bus, logind moved": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{logind, "", ":1.5"}}, false},
235 "bus, other name": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{"x.y", "", ""}}, false},
236 "client forging it": {&dbus.Signal{Sender: ":1.9", Name: busDaemon + ".NameOwnerChanged", Body: body}, false},
237 "other signal": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameAcquired", Body: body}, false},
238 "malformed body": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{1, 2, 3}}, false},
239 "short body": {&dbus.Signal{Sender: busDaemon, Name: busDaemon + ".NameOwnerChanged", Body: []any{logind}}, false},
240 } {
241 if got := lost(tc.sig); got != tc.want {
242 t.Errorf("%s: lost = %v, want %v", name, got, tc.want)
243 }
244 }
245}