1package control
2
3import (
4 "context"
5 "errors"
6 "io"
7 "net"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
19 "golang.org/x/sys/unix"
20)
21
22const helperEnv = "NEFERCAP_CONTROL_TEST_HELPER"
23
24// TestMain doubles as a helper process: with helperEnv set, the test binary
25// runs a real recording owner instead of the tests.
26func TestMain(m *testing.M) {
27 switch os.Getenv(helperEnv) {
28 case "":
29 os.Exit(m.Run())
30 case "owner":
31 os.Exit(helperOwner())
32 }
33 os.Exit(64)
34}
35
36func helperOwner() int {
37 s, err := Listen()
38 if errors.Is(err, ErrAlreadyRecording) {
39 return 3
40 }
41 if err != nil {
42 return 4
43 }
44 os.Stdout.WriteString("ready\n")
45 if os.Getenv(helperEnv+"_HANG") != "" {
46 select {} // killed by the test
47 }
48 <-s.Requests()
49 if s.Close() != nil {
50 return 5
51 }
52 return 0
53}
54
55// setup points XDG_RUNTIME_DIR at a fresh private temp directory.
56func setup(t *testing.T) string {
57 t.Helper()
58 rd := t.TempDir()
59 t.Setenv("XDG_RUNTIME_DIR", rd)
60 return rd
61}
62
63func listenT(t *testing.T) *Server {
64 t.Helper()
65 s, err := Listen()
66 require.NoError(t, err)
67 t.Cleanup(func() { s.Close() })
68 return s
69}
70
71func ctx(t *testing.T) context.Context {
72 t.Helper()
73 c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
74 t.Cleanup(cancel)
75 return c
76}
77
78func sockPath(rd string) string { return filepath.Join(rd, dirName, sockName) }
79
80func dial(t *testing.T, rd string) *net.UnixConn {
81 t.Helper()
82 c, err := net.Dial("unix", sockPath(rd))
83 require.NoError(t, err)
84 t.Cleanup(func() { c.Close() })
85 return c.(*net.UnixConn)
86}
87
88func startHelper(t *testing.T, extra ...string) (*exec.Cmd, io.ReadCloser) {
89 t.Helper()
90 cmd := exec.Command(os.Args[0], "-test.run=^$")
91 cmd.Env = append(os.Environ(), append([]string{helperEnv + "=owner"}, extra...)...)
92 out, err := cmd.StdoutPipe()
93 require.NoError(t, err)
94 require.NoError(t, cmd.Start())
95 t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
96 return cmd, out
97}
98
99func waitReady(t *testing.T, out io.Reader) {
100 t.Helper()
101 buf := make([]byte, len("ready\n"))
102 _, err := io.ReadFull(out, buf)
103 require.NoError(t, err)
104 require.Equal(t, "ready\n", string(buf))
105}
106
107func TestStopAndStatusRoundTrip(t *testing.T) {
108 setup(t)
109 s := listenT(t)
110
111 ok, err := Query(ctx(t))
112 require.NoError(t, err)
113 assert.True(t, ok)
114 select {
115 case r := <-s.Requests():
116 t.Fatalf("status was delivered as %v", r)
117 default:
118 }
119
120 ok, err = Stop(ctx(t))
121 require.NoError(t, err)
122 assert.True(t, ok)
123 select {
124 case r := <-s.Requests():
125 assert.Equal(t, RequestStop, r)
126 case <-time.After(5 * time.Second):
127 t.Fatal("stop request not delivered")
128 }
129}
130
131func TestNoServer(t *testing.T) {
132 rd := setup(t)
133 ok, err := Stop(ctx(t))
134 assert.NoError(t, err)
135 assert.False(t, ok)
136 ok, err = Query(ctx(t))
137 assert.NoError(t, err)
138 assert.False(t, ok)
139 // Clients never create anything.
140 _, err = os.Lstat(filepath.Join(rd, dirName))
141 assert.True(t, os.IsNotExist(err))
142}
143
144func TestRuntimeDirValidation(t *testing.T) {
145 for _, v := range []string{"", "relative/dir", strings.Repeat("/a", maxPath)} {
146 t.Setenv("XDG_RUNTIME_DIR", v)
147 _, err := Listen()
148 assert.ErrorIs(t, err, ErrNoRuntimeDir, "%q", v)
149 _, err = Stop(ctx(t))
150 assert.ErrorIs(t, err, ErrNoRuntimeDir)
151 _, err = Query(ctx(t))
152 assert.ErrorIs(t, err, ErrNoRuntimeDir)
153 }
154}
155
156func TestListenRejectsRawRuntimeDir(t *testing.T) {
157 for _, v := range []string{"", "relative", "/tmp/a\x00b"} {
158 _, err := listen(v, uint32(os.Geteuid()))
159 assert.ErrorIs(t, err, ErrNoRuntimeDir, "%q", v)
160 }
161}
162
163func TestPermissions(t *testing.T) {
164 rd := setup(t)
165 old := unix.Umask(0o000)
166 defer unix.Umask(old)
167 listenT(t)
168
169 for path, mode := range map[string]os.FileMode{
170 filepath.Join(rd, dirName): 0o700,
171 sockPath(rd): 0o600,
172 filepath.Join(rd, dirName, lockName): 0o600,
173 } {
174 fi, err := os.Lstat(path)
175 require.NoError(t, err)
176 assert.Equal(t, mode, fi.Mode().Perm(), filepath.Base(path))
177 }
178 fi, err := os.Lstat(sockPath(rd))
179 require.NoError(t, err)
180 assert.NotZero(t, fi.Mode()&os.ModeSocket)
181}
182
183func TestSingleInstance(t *testing.T) {
184 rd := setup(t)
185 first := listenT(t)
186
187 second, err := Listen()
188 require.ErrorIs(t, err, ErrAlreadyRecording)
189 assert.Nil(t, second)
190
191 // The failed attempt must not have disturbed the live socket.
192 ok, err := Query(ctx(t))
193 require.NoError(t, err)
194 assert.True(t, ok)
195
196 require.NoError(t, first.Close())
197 _, err = os.Lstat(sockPath(rd))
198 assert.True(t, os.IsNotExist(err))
199
200 third, err := Listen()
201 require.NoError(t, err)
202 require.NoError(t, third.Close())
203}
204
205func TestSingleInstanceAcrossProcesses(t *testing.T) {
206 rd := setup(t)
207 cmd, out := startHelper(t)
208 waitReady(t, out)
209
210 // The other process holds the lock, so we neither serve nor unlink.
211 _, err := Listen()
212 require.ErrorIs(t, err, ErrAlreadyRecording)
213 _, err = os.Lstat(sockPath(rd))
214 require.NoError(t, err)
215
216 second := exec.Command(os.Args[0], "-test.run=^$")
217 second.Env = append(os.Environ(), helperEnv+"=owner")
218 err = second.Run()
219 var ee *exec.ExitError
220 require.ErrorAs(t, err, &ee)
221 assert.Equal(t, 3, ee.ExitCode())
222
223 ok, err := Stop(ctx(t))
224 require.NoError(t, err)
225 assert.True(t, ok)
226 require.NoError(t, cmd.Wait())
227 _, err = os.Lstat(sockPath(rd))
228 assert.True(t, os.IsNotExist(err), "owner removes its socket on close")
229}
230
231func TestStaleSocketAfterKilledOwner(t *testing.T) {
232 rd := setup(t)
233 cmd, out := startHelper(t, helperEnv+"_HANG=1")
234 waitReady(t, out)
235 require.NoError(t, cmd.Process.Kill())
236 _ = cmd.Wait()
237
238 _, err := os.Lstat(sockPath(rd))
239 require.NoError(t, err, "killed owner leaves a stale socket")
240
241 // Clients report "not recording" and leave the stale inode alone.
242 ok, err := Query(ctx(t))
243 assert.NoError(t, err)
244 assert.False(t, ok)
245 ok, err = Stop(ctx(t))
246 assert.NoError(t, err)
247 assert.False(t, ok)
248 _, err = os.Lstat(sockPath(rd))
249 require.NoError(t, err, "clients must not unlink")
250
251 // The next lock holder replaces it.
252 s := listenT(t)
253 ok, err = Query(ctx(t))
254 require.NoError(t, err)
255 assert.True(t, ok)
256 require.NoError(t, s.Close())
257}
258
259func TestStaleLeftoverWithoutLockedOwner(t *testing.T) {
260 rd := setup(t)
261 s, err := Listen()
262 require.NoError(t, err)
263 // Simulate a crash: drop the lock but leave the socket inode behind.
264 require.NoError(t, unix.Close(s.lockFD))
265 s.lockFD = -1
266 require.NoError(t, s.ln.Close())
267 _, err = os.Lstat(sockPath(rd))
268 require.NoError(t, err)
269
270 s2 := listenT(t)
271 ok, err := Query(ctx(t))
272 require.NoError(t, err)
273 assert.True(t, ok)
274 require.NoError(t, s2.Close())
275}
276
277func TestListenRefusesForeignObjects(t *testing.T) {
278 t.Run("non-socket at socket path", func(t *testing.T) {
279 rd := setup(t)
280 require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
281 require.NoError(t, os.WriteFile(sockPath(rd), []byte("keep"), 0o600))
282 _, err := Listen()
283 require.ErrorIs(t, err, ErrInsecure)
284 b, err := os.ReadFile(sockPath(rd))
285 require.NoError(t, err)
286 assert.Equal(t, "keep", string(b))
287 })
288 t.Run("symlink at socket path", func(t *testing.T) {
289 rd := setup(t)
290 require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
291 target := filepath.Join(rd, "target")
292 require.NoError(t, os.WriteFile(target, []byte("keep"), 0o600))
293 require.NoError(t, os.Symlink(target, sockPath(rd)))
294 _, err := Listen()
295 require.ErrorIs(t, err, ErrInsecure)
296 _, err = os.Stat(target)
297 assert.NoError(t, err)
298 })
299 t.Run("symlinked lock file", func(t *testing.T) {
300 rd := setup(t)
301 require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
302 target := filepath.Join(rd, "target")
303 require.NoError(t, os.WriteFile(target, nil, 0o644))
304 require.NoError(t, os.Symlink(target, filepath.Join(rd, dirName, lockName)))
305 _, err := Listen()
306 require.Error(t, err)
307 fi, err := os.Stat(target)
308 require.NoError(t, err)
309 assert.Equal(t, os.FileMode(0o644), fi.Mode().Perm(), "target untouched")
310 })
311}
312
313func TestDirectoryChecks(t *testing.T) {
314 t.Run("loose mode", func(t *testing.T) {
315 rd := setup(t)
316 d := filepath.Join(rd, dirName)
317 require.NoError(t, os.Mkdir(d, 0o700))
318 require.NoError(t, os.Chmod(d, 0o755))
319 _, err := Listen()
320 assert.ErrorIs(t, err, ErrInsecure)
321 _, err = Stop(ctx(t))
322 assert.ErrorIs(t, err, ErrInsecure)
323 _, err = Query(ctx(t))
324 assert.ErrorIs(t, err, ErrInsecure)
325 })
326 t.Run("symlink", func(t *testing.T) {
327 rd := setup(t)
328 real := filepath.Join(rd, "elsewhere")
329 require.NoError(t, os.Mkdir(real, 0o700))
330 require.NoError(t, os.Symlink(real, filepath.Join(rd, dirName)))
331 _, err := Listen()
332 assert.ErrorIs(t, err, ErrInsecure)
333 _, err = Stop(ctx(t))
334 assert.ErrorIs(t, err, ErrInsecure)
335 entries, err := os.ReadDir(real)
336 require.NoError(t, err)
337 assert.Empty(t, entries)
338 })
339 t.Run("regular file", func(t *testing.T) {
340 rd := setup(t)
341 require.NoError(t, os.WriteFile(filepath.Join(rd, dirName), nil, 0o600))
342 _, err := Listen()
343 assert.ErrorIs(t, err, ErrInsecure)
344 })
345 t.Run("other owner", func(t *testing.T) {
346 rd := setup(t)
347 require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
348 fd, err := openSecureDir(rd, false, uint32(os.Geteuid())+1)
349 if err == nil {
350 unix.Close(fd)
351 }
352 assert.ErrorIs(t, err, ErrInsecure)
353 })
354}
355
356func TestClientRejectsLooseSocket(t *testing.T) {
357 rd := setup(t)
358 listenT(t)
359 require.NoError(t, os.Chmod(sockPath(rd), 0o666))
360 _, err := Stop(ctx(t))
361 assert.ErrorIs(t, err, ErrInsecure)
362}
363
364func TestForeignPeerRejected(t *testing.T) {
365 setup(t)
366 s, err := listen(os.Getenv("XDG_RUNTIME_DIR"), uint32(os.Geteuid())+1)
367 require.NoError(t, err)
368 t.Cleanup(func() { s.Close() })
369
370 ok, err := Stop(ctx(t))
371 assert.Error(t, err)
372 assert.False(t, ok)
373 select {
374 case <-s.Requests():
375 t.Fatal("foreign peer delivered a request")
376 case <-time.After(100 * time.Millisecond):
377 }
378}
379
380func TestRequestLimitsAndProtocol(t *testing.T) {
381 rd := setup(t)
382 s := listenT(t)
383
384 expectClosedSilently := func(t *testing.T, payload string) {
385 t.Helper()
386 c := dial(t, rd)
387 _, _ = c.Write([]byte(payload))
388 c.SetReadDeadline(time.Now().Add(3 * time.Second))
389 b, err := io.ReadAll(c)
390 if err != nil {
391 // Unread request bytes make close report a reset; still no reply.
392 require.ErrorIs(t, err, unix.ECONNRESET)
393 }
394 assert.Empty(t, b)
395 }
396 t.Run("oversized without newline", func(t *testing.T) {
397 expectClosedSilently(t, strings.Repeat("x", 4096))
398 })
399 t.Run("oversized with newline late", func(t *testing.T) {
400 expectClosedSilently(t, strings.Repeat("x", maxRequest)+"stop\n")
401 })
402 t.Run("unknown", func(t *testing.T) { expectClosedSilently(t, "quit\n") })
403 t.Run("case sensitive", func(t *testing.T) { expectClosedSilently(t, "STOP\n") })
404 t.Run("crlf", func(t *testing.T) { expectClosedSilently(t, "stop\r\n") })
405 t.Run("stop in oversized junk", func(t *testing.T) {
406 expectClosedSilently(t, "status\nstop\n"+strings.Repeat("x", 100))
407 })
408
409 select {
410 case r := <-s.Requests():
411 t.Fatalf("invalid request delivered: %v", r)
412 default:
413 }
414
415 // Replies are exactly the two fixed lines.
416 c := dial(t, rd)
417 _, err := c.Write([]byte("status\n"))
418 require.NoError(t, err)
419 b, err := io.ReadAll(c)
420 require.NoError(t, err)
421 assert.Equal(t, "recording\n", string(b))
422}
423
424func TestDeadlineAndConnectionBound(t *testing.T) {
425 rd := setup(t)
426 listenT(t)
427
428 // Fill every slot with silent clients.
429 start := time.Now()
430 var idle []*net.UnixConn
431 for i := 0; i < connLimit; i++ {
432 idle = append(idle, dial(t, rd))
433 }
434 // While all slots are taken nothing is answered.
435 short, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
436 ok, err := Query(short)
437 cancel()
438 assert.Error(t, err)
439 assert.False(t, ok)
440
441 // The server drops the silent clients at its 1s deadline.
442 for _, c := range idle {
443 c.SetReadDeadline(time.Now().Add(3 * time.Second))
444 b, err := io.ReadAll(c)
445 require.NoError(t, err, "server closes timed-out clients")
446 assert.Empty(t, b)
447 }
448 assert.GreaterOrEqual(t, time.Since(start), ioTimeout-100*time.Millisecond)
449 assert.Less(t, time.Since(start), 3*time.Second)
450
451 // Slots are free again.
452 ok, err = Query(ctx(t))
453 require.NoError(t, err)
454 assert.True(t, ok)
455}
456
457func TestClientContext(t *testing.T) {
458 rd := setup(t)
459 listenT(t)
460 // Occupy every slot so the client's request stays unanswered.
461 for i := 0; i < connLimit; i++ {
462 dial(t, rd)
463 }
464 c, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
465 defer cancel()
466 start := time.Now()
467 ok, err := Query(c)
468 assert.Error(t, err)
469 assert.False(t, ok)
470 assert.Less(t, time.Since(start), 900*time.Millisecond)
471
472 cancelled, cancel2 := context.WithCancel(context.Background())
473 cancel2()
474 _, err = Stop(cancelled)
475 assert.Error(t, err)
476}
477
478func TestConcurrentStopAndStatus(t *testing.T) {
479 setup(t)
480 s := listenT(t)
481 base := runtime.NumGoroutine()
482
483 const n = 32
484 var wg sync.WaitGroup
485 errs := make(chan error, 2*n)
486 for i := 0; i < n; i++ {
487 wg.Add(2)
488 go func() {
489 defer wg.Done()
490 ok, err := Stop(ctx(t))
491 if err == nil && !ok {
492 err = errors.New("stop not acknowledged")
493 }
494 errs <- err
495 }()
496 go func() {
497 defer wg.Done()
498 ok, err := Query(ctx(t))
499 if err == nil && !ok {
500 err = errors.New("status false")
501 }
502 errs <- err
503 }()
504 }
505 wg.Wait()
506 close(errs)
507 for err := range errs {
508 assert.NoError(t, err)
509 }
510
511 // Stops coalesce into a single pending request.
512 require.Equal(t, RequestStop, <-s.Requests())
513 select {
514 case <-s.Requests():
515 t.Fatal("stops must coalesce")
516 default:
517 }
518
519 require.NoError(t, s.Close())
520 assert.Eventually(t, func() bool { return runtime.NumGoroutine() <= base }, 3*time.Second, 20*time.Millisecond,
521 "server goroutines must exit on Close")
522}
523
524func TestStopAckDoesNotBlockOnUnreadRequests(t *testing.T) {
525 setup(t)
526 listenT(t) // nobody reads Requests
527 for i := 0; i < 3; i++ {
528 ok, err := Stop(ctx(t))
529 require.NoError(t, err)
530 assert.True(t, ok)
531 }
532}
533
534func TestCloseLifecycle(t *testing.T) {
535 rd := setup(t)
536 s, err := Listen()
537 require.NoError(t, err)
538
539 idle := dial(t, rd) // an in-flight connection must not delay Close
540 done := make(chan struct{})
541 var wg sync.WaitGroup
542 for i := 0; i < 4; i++ {
543 wg.Add(1)
544 go func() { defer wg.Done(); assert.NoError(t, s.Close()) }()
545 }
546 go func() { wg.Wait(); close(done) }()
547 select {
548 case <-done:
549 case <-time.After(3 * time.Second):
550 t.Fatal("Close blocked")
551 }
552 assert.NoError(t, s.Close(), "idempotent")
553
554 idle.SetReadDeadline(time.Now().Add(time.Second))
555 _, err = io.ReadAll(idle)
556 if err != nil { // a not-yet-accepted connection is reset by the listener close
557 assert.ErrorIs(t, err, unix.ECONNRESET)
558 }
559
560 _, err = os.Lstat(sockPath(rd))
561 assert.True(t, os.IsNotExist(err))
562 fi, err := os.Lstat(filepath.Join(rd, dirName, lockName))
563 require.NoError(t, err, "lock file persists")
564 assert.Equal(t, int64(0), fi.Size(), "lock file stays empty")
565
566 ok, err := Stop(ctx(t))
567 assert.NoError(t, err)
568 assert.False(t, ok)
569}
570
571func TestCloseKeepsReplacedSocketPath(t *testing.T) {
572 rd := setup(t)
573 s, err := Listen()
574 require.NoError(t, err)
575 // Someone swaps our inode for something else; Close must not remove it.
576 require.NoError(t, os.Remove(sockPath(rd)))
577 require.NoError(t, os.WriteFile(sockPath(rd), []byte("other"), 0o600))
578 require.NoError(t, s.Close())
579 b, err := os.ReadFile(sockPath(rd))
580 require.NoError(t, err)
581 assert.Equal(t, "other", string(b))
582}
583
584func TestListenFailureLeavesNothingBehind(t *testing.T) {
585 rd := setup(t)
586 require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
587 require.NoError(t, os.WriteFile(sockPath(rd), nil, 0o600))
588 _, err := Listen()
589 require.Error(t, err)
590 // The lock must have been released by the failed attempt.
591 require.NoError(t, os.Remove(sockPath(rd)))
592 s, err := Listen()
593 require.NoError(t, err)
594 require.NoError(t, s.Close())
595}
596
597func TestLongRuntimeDir(t *testing.T) {
598 long := filepath.Join(t.TempDir(), strings.Repeat("d", 120), strings.Repeat("e", 120))
599 require.NoError(t, os.MkdirAll(long, 0o700))
600 t.Setenv("XDG_RUNTIME_DIR", long)
601 s := listenT(t) // longer than sun_path, works through the directory fd
602 ok, err := Query(ctx(t))
603 require.NoError(t, err)
604 assert.True(t, ok)
605 require.NoError(t, s.Close())
606}