v0.3.0 0a8834d

control_test.go RawHistoryFolder

1package control
2
3import (
4	"context"
5	"io"
6	"net"
7	"os"
8	"path/filepath"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/bnema/neferafk/internal/ports"
14	"github.com/bnema/zerowrap"
15)
16
17func socketPath(t *testing.T) string {
18	t.Helper()
19	// Unix socket paths are short-limited: keep the temp dir shallow.
20	dir, err := os.MkdirTemp("", "nc")
21	if err != nil {
22		t.Fatal(err)
23	}
24	t.Cleanup(func() { os.RemoveAll(dir) })
25	return filepath.Join(dir, "neferafk", "control.sock")
26}
27
28// start serves with allowed as the only accepted UID and answers requests with
29// a channel-driven owner stand-in (a goroutine, not a project-interface double).
30func start(t *testing.T, allowed uint32) (path string, requests <-chan ports.ControlKind) {
31	t.Helper()
32	path = socketPath(t)
33	in := make(chan ports.ControlRequest)
34	srv, err := Listen(path, allowed, in, zerowrap.New(zerowrap.Config{Output: io.Discard}))
35	if err != nil {
36		t.Fatal(err)
37	}
38	seen := make(chan ports.ControlKind, 8)
39	ctx, cancel := context.WithCancel(context.Background())
40	done := make(chan struct{})
41	go func() { srv.Serve(ctx); close(done) }()
42	go func() {
43		for {
44			select {
45			case r := <-in:
46				seen <- r.Kind
47				r.Reply <- ports.ControlReply{Generation: 3, Protected: r.Kind == ports.ControlLock}
48			case <-ctx.Done():
49				return
50			}
51		}
52	}()
53	t.Cleanup(func() { cancel(); <-done })
54	return path, seen
55}
56
57func TestPermissionsAndCommands(t *testing.T) {
58	path, seen := start(t, uint32(os.Getuid()))
59	di, _ := os.Stat(filepath.Dir(path))
60	si, _ := os.Lstat(path)
61	if di.Mode().Perm() != 0o700 || si.Mode().Perm() != 0o600 || si.Mode()&os.ModeSocket == 0 {
62		t.Fatalf("dir %v socket %v", di.Mode(), si.Mode())
63	}
64	got, err := Do(context.Background(), path, "status")
65	if err != nil || got != "ok generation=3 acquiring=false protected=false faded=false off=false" {
66		t.Fatalf("%q %v", got, err)
67	}
68	if got, err := Do(context.Background(), path, "lock"); err != nil || !strings.Contains(got, "protected=true") {
69		t.Fatalf("%q %v", got, err)
70	}
71	if k1, k2 := <-seen, <-seen; k1 != ports.ControlStatus || k2 != ports.ControlLock {
72		t.Fatalf("%v %v", k1, k2)
73	}
74}
75
76func TestUnknownCommandsRejectedNeverReachOwner(t *testing.T) {
77	path, seen := start(t, uint32(os.Getuid()))
78	for _, cmd := range []string{"unlock", "UNLOCK", "lock now", "lock ", " lock", "", "status\nunlock", strings.Repeat("a", 4096)} {
79		c, err := net.Dial("unix", path)
80		if err != nil {
81			t.Fatal(err)
82		}
83		c.SetDeadline(time.Now().Add(2 * time.Second))
84		io.WriteString(c, cmd+"\n")
85		reply, _ := io.ReadAll(c)
86		c.Close()
87		if string(reply) != "err unknown command\n" && !(cmd == "status\nunlock" && strings.HasPrefix(string(reply), "ok ")) {
88			t.Fatalf("%q -> %q", cmd, reply)
89		}
90	}
91	// "status\nunlock" is a valid status line followed by ignored input.
92	if k := <-seen; k != ports.ControlStatus {
93		t.Fatalf("kind %v", k)
94	}
95	select {
96	case k := <-seen:
97		t.Fatalf("unexpected request %v", k)
98	case <-time.After(50 * time.Millisecond):
99	}
100	if _, err := Do(context.Background(), path, "unlock"); err == nil {
101		t.Fatal("client got success for unlock")
102	}
103}
104
105func TestForeignUIDRejectedWithoutReply(t *testing.T) {
106	// Only uid+1 is allowed, so this process's own credentials are foreign.
107	path, seen := start(t, uint32(os.Getuid())+1)
108	if got, err := Do(context.Background(), path, "lock"); err == nil {
109		t.Fatalf("foreign peer served: %q", got)
110	}
111	select {
112	case k := <-seen:
113		t.Fatalf("foreign request reached owner: %v", k)
114	case <-time.After(50 * time.Millisecond):
115	}
116}
117
118func TestListenRefusesLiveDaemonReplacesStaleSocketAndRejectsNonSocket(t *testing.T) {
119	path, _ := start(t, uint32(os.Getuid()))
120	if _, err := Listen(path, uint32(os.Getuid()), make(chan ports.ControlRequest), zerowrap.New(zerowrap.Config{Output: io.Discard})); err == nil {
121		t.Fatal("second daemon took the live socket")
122	}
123	stale := socketPath(t)
124	os.MkdirAll(filepath.Dir(stale), 0o700)
125	ln, err := net.Listen("unix", stale)
126	if err != nil {
127		t.Fatal(err)
128	}
129	ln.(*net.UnixListener).SetUnlinkOnClose(false)
130	ln.Close()
131	srv, err := Listen(stale, uint32(os.Getuid()), make(chan ports.ControlRequest), zerowrap.New(zerowrap.Config{Output: io.Discard}))
132	if err != nil {
133		t.Fatalf("stale socket not replaced: %v", err)
134	}
135	srv.ln.Close()
136	file := socketPath(t)
137	os.MkdirAll(filepath.Dir(file), 0o700)
138	os.WriteFile(file, nil, 0o600)
139	if _, err := Listen(file, uint32(os.Getuid()), make(chan ports.ControlRequest), zerowrap.New(zerowrap.Config{Output: io.Discard})); err == nil {
140		t.Fatal("regular file replaced")
141	}
142}
143
144func TestListenTightensLooseDirectoryAndRejectsForeignDirectory(t *testing.T) {
145	path := socketPath(t)
146	os.MkdirAll(filepath.Dir(path), 0o755)
147	os.Chmod(filepath.Dir(path), 0o755)
148	srv, err := Listen(path, uint32(os.Getuid()), make(chan ports.ControlRequest), zerowrap.New(zerowrap.Config{Output: io.Discard}))
149	if err != nil {
150		t.Fatal(err)
151	}
152	srv.ln.Close()
153	if fi, _ := os.Stat(filepath.Dir(path)); fi.Mode().Perm() != 0o700 {
154		t.Fatalf("dir mode %v", fi.Mode())
155	}
156	// A directory that is not ours (root's /proc) is refused.
157	if _, err := Listen("/proc/neferafk/control.sock", uint32(os.Getuid()), make(chan ports.ControlRequest), zerowrap.New(zerowrap.Config{Output: io.Discard})); err == nil {
158		t.Fatal("foreign directory accepted")
159	}
160}
161
162func TestDefaultPathRequiresAbsoluteRuntimeDir(t *testing.T) {
163	t.Setenv("XDG_RUNTIME_DIR", "")
164	if _, err := DefaultPath(); err == nil {
165		t.Fatal("empty runtime dir accepted")
166	}
167	t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000")
168	if p, err := DefaultPath(); err != nil || p != "/run/user/1000/neferafk/control.sock" {
169		t.Fatalf("%q %v", p, err)
170	}
171}

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