v0.3.0 ea9abc7

control_test.go RawHistoryFolder

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}

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