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}