1package localadmin
2
3import (
4 "context"
5 "io/fs"
6 "net"
7 "os"
8 "path/filepath"
9 "strconv"
10 "syscall"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16)
17
18func TestEnsureRuntimeDirPrefersXDG(t *testing.T) {
19 xdg := t.TempDir()
20 t.Setenv("XDG_RUNTIME_DIR", xdg)
21
22 dir, err := EnsureRuntimeDir()
23 require.NoError(t, err)
24 assert.Equal(t, filepath.Join(xdg, "gordon"), dir)
25
26 fi, err := os.Lstat(dir)
27 require.NoError(t, err)
28 assert.Equal(t, fs.FileMode(0o700), fi.Mode().Perm())
29}
30
31func TestEnsureRuntimeDirFallsBackToHome(t *testing.T) {
32 home := t.TempDir()
33 t.Setenv("XDG_RUNTIME_DIR", "")
34 t.Setenv("HOME", home)
35
36 dir, err := EnsureRuntimeDir()
37 require.NoError(t, err)
38 assert.Equal(t, filepath.Join(home, ".gordon", "run"), dir)
39}
40
41func TestEnsureRuntimeDirRejectsUnsafeXDG(t *testing.T) {
42 xdg := t.TempDir()
43 // Make the selected XDG gordon path a symlink. Falling back would silently
44 // change daemon identity, so an unsafe selected path fails closed.
45 require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(xdg, "gordon")))
46 t.Setenv("XDG_RUNTIME_DIR", xdg)
47 t.Setenv("HOME", t.TempDir())
48
49 _, err := EnsureRuntimeDir()
50 require.Error(t, err)
51 assert.ErrorIs(t, err, ErrUnsafePath)
52}
53
54func TestEnsureRuntimeDirRejectsRelativeXDG(t *testing.T) {
55 home := t.TempDir()
56 t.Setenv("XDG_RUNTIME_DIR", filepath.Join("relative", "runtime"))
57 t.Setenv("HOME", home)
58
59 _, err := EnsureRuntimeDir()
60 require.Error(t, err)
61 assert.ErrorIs(t, err, ErrUnsafePath)
62 // Fail closed: no silent fallback to $HOME.
63 assert.NoDirExists(t, filepath.Join(home, ".gordon", "run"))
64}
65
66func TestEnsureRuntimeDirNoFallbackOnCreateError(t *testing.T) {
67 // An overlong XDG_RUNTIME_DIR component makes ancestor inspection fail
68 // with ENAMETOOLONG, which is not ErrUnsafePath. The preferred candidate
69 // must fail immediately rather than silently falling back to $HOME.
70 base := t.TempDir()
71 longName := make([]byte, 5000)
72 for i := range longName {
73 longName[i] = 'x'
74 }
75 home := t.TempDir()
76 t.Setenv("XDG_RUNTIME_DIR", filepath.Join(base, string(longName)))
77 t.Setenv("HOME", home)
78
79 _, err := EnsureRuntimeDir()
80 require.Error(t, err)
81 assert.NotErrorIs(t, err, ErrUnsafePath)
82 assert.NoDirExists(t, filepath.Join(home, ".gordon", "run"))
83}
84
85func TestEnsureDirRefusesSymlinkAncestor(t *testing.T) {
86 target := t.TempDir()
87 link := filepath.Join(t.TempDir(), "link")
88 require.NoError(t, os.Symlink(target, link))
89
90 dir := filepath.Join(link, "gordon")
91 err := EnsureDir(dir)
92 require.Error(t, err)
93 assert.ErrorIs(t, err, ErrUnsafePath)
94 // Nothing must be created through the symlinked ancestor.
95 assert.NoDirExists(t, filepath.Join(target, "gordon"))
96 assert.NoFileExists(t, dir)
97}
98
99func TestEnsureDirTightensPermissions(t *testing.T) {
100 dir := filepath.Join(t.TempDir(), "gordon")
101 require.NoError(t, os.Mkdir(dir, 0o755))
102
103 require.NoError(t, EnsureDir(dir))
104
105 fi, err := os.Lstat(dir)
106 require.NoError(t, err)
107 assert.Equal(t, fs.FileMode(0o700), fi.Mode().Perm())
108}
109
110func TestEnsureDirRejectsOwnerOwnedWritableAncestor(t *testing.T) {
111 ancestor := t.TempDir()
112 require.NoError(t, os.Chmod(ancestor, 0o770))
113
114 dir := filepath.Join(ancestor, "nested", "gordon")
115 err := EnsureDir(dir)
116 require.Error(t, err)
117 assert.ErrorIs(t, err, ErrUnsafePath)
118 assert.NoDirExists(t, filepath.Join(ancestor, "nested"))
119}
120
121type fileInfoWithOwner struct {
122 os.FileInfo
123 uid uint32
124}
125
126func (fi fileInfoWithOwner) Sys() any {
127 return &syscall.Stat_t{Uid: fi.uid}
128}
129
130func TestValidatePathComponentRejectsForeignOwnedAncestors(t *testing.T) {
131 base, err := os.Lstat(t.TempDir())
132 require.NoError(t, err)
133
134 for _, mode := range []fs.FileMode{0o755, 0o700} {
135 t.Run(mode.String(), func(t *testing.T) {
136 fi := fileInfoWithOwner{FileInfo: fileInfoWithMode{FileInfo: base, mode: os.ModeDir | mode}, uid: 2000}
137 err := validatePathComponentForUID(fi, "/runtime", 1000)
138 require.Error(t, err)
139 assert.ErrorIs(t, err, ErrUnsafePath)
140 assert.Contains(t, err.Error(), "owned by uid 2000")
141 })
142 }
143}
144
145type fileInfoWithMode struct {
146 os.FileInfo
147 mode fs.FileMode
148}
149
150func (fi fileInfoWithMode) Mode() fs.FileMode { return fi.mode }
151
152func TestValidatePathComponentAllowsRootOwnedStickyAncestor(t *testing.T) {
153 base, err := os.Lstat(t.TempDir())
154 require.NoError(t, err)
155 fi := fileInfoWithOwner{
156 FileInfo: fileInfoWithMode{FileInfo: base, mode: os.ModeDir | os.ModeSticky | 0o777},
157 uid: 0,
158 }
159
160 assert.NoError(t, validatePathComponentForUID(fi, "/tmp", 1000))
161}
162
163func TestValidateDirAllowsStickyRootAncestor(t *testing.T) {
164 root := os.TempDir()
165 fi, err := os.Lstat(root)
166 require.NoError(t, err)
167 st, ok := fi.Sys().(*syscall.Stat_t)
168 if !ok || st.Uid != 0 || fi.Mode()&os.ModeSticky == 0 || fi.Mode().Perm()&0o022 == 0 {
169 t.Skip("system temporary directory is not writable sticky-root")
170 }
171
172 dir := filepath.Join(t.TempDir(), "gordon")
173 require.NoError(t, os.Mkdir(dir, DirPermissions))
174 assert.NoError(t, ValidateDir(dir))
175}
176
177func TestEnsureDirRejectsSymlinkDirectory(t *testing.T) {
178 target := t.TempDir()
179 link := filepath.Join(t.TempDir(), "gordon")
180 require.NoError(t, os.Symlink(target, link))
181
182 err := EnsureDir(link)
183 require.Error(t, err)
184 assert.ErrorIs(t, err, ErrUnsafePath)
185}
186
187func TestEnsureDirRejectsRegularFile(t *testing.T) {
188 path := filepath.Join(t.TempDir(), "gordon")
189 require.NoError(t, os.WriteFile(path, []byte("x"), 0o600))
190
191 err := EnsureDir(path)
192 require.Error(t, err)
193}
194
195func TestEnsureDirRejectsForeignOwner(t *testing.T) {
196 if os.Geteuid() != 0 {
197 t.Skip("requires root to change directory ownership")
198 }
199 dir := filepath.Join(t.TempDir(), "gordon")
200 require.NoError(t, os.Mkdir(dir, 0o700))
201 require.NoError(t, os.Chown(dir, 65534, 65534))
202
203 err := EnsureDir(dir)
204 require.Error(t, err)
205 assert.ErrorIs(t, err, ErrUnsafePath)
206}
207
208func TestListenCreatesOwnerOnlySocket(t *testing.T) {
209 dir := filepath.Join(t.TempDir(), "gordon")
210
211 ln, info, err := Listen(dir)
212 require.NoError(t, err)
213 t.Cleanup(func() { _ = ln.Close() })
214
215 path := SocketPath(dir)
216 fi, err := os.Lstat(path)
217 require.NoError(t, err)
218 assert.NotZero(t, fi.Mode()&os.ModeSocket)
219 assert.Equal(t, fs.FileMode(0o600), fi.Mode().Perm())
220 assert.True(t, os.SameFile(info, fi))
221 require.NoError(t, ValidateSocket(path))
222}
223
224func TestListenRefusesActiveListener(t *testing.T) {
225 dir := filepath.Join(t.TempDir(), "gordon")
226
227 ln, _, err := Listen(dir)
228 require.NoError(t, err)
229 t.Cleanup(func() { _ = ln.Close() })
230
231 go func() {
232 for {
233 conn, acceptErr := ln.Accept()
234 if acceptErr != nil {
235 return
236 }
237 _ = conn.Close()
238 }
239 }()
240
241 _, _, err = Listen(dir)
242 require.Error(t, err)
243 assert.ErrorIs(t, err, ErrActiveListener)
244 assert.FileExists(t, SocketPath(dir))
245}
246
247func TestListenRemovesStaleOwnedSocket(t *testing.T) {
248 dir := filepath.Join(t.TempDir(), "gordon")
249 require.NoError(t, EnsureDir(dir))
250
251 stale, err := net.ListenUnix("unix", &net.UnixAddr{Name: SocketPath(dir), Net: "unix"})
252 require.NoError(t, err)
253 stale.SetUnlinkOnClose(false)
254 require.NoError(t, stale.Close())
255 require.FileExists(t, SocketPath(dir))
256
257 ln, _, err := Listen(dir)
258 require.NoError(t, err)
259 t.Cleanup(func() { _ = ln.Close() })
260 assert.FileExists(t, SocketPath(dir))
261}
262
263func TestListenRefusesRegularFileCollision(t *testing.T) {
264 dir := filepath.Join(t.TempDir(), "gordon")
265 require.NoError(t, EnsureDir(dir))
266 path := SocketPath(dir)
267 require.NoError(t, os.WriteFile(path, []byte("not a socket"), 0o600))
268
269 _, _, err := Listen(dir)
270 require.Error(t, err)
271 assert.ErrorIs(t, err, ErrUnsafePath)
272 // The unknown file is never deleted or replaced.
273 assert.FileExists(t, path)
274}
275
276func TestListenRefusesSymlinkSocket(t *testing.T) {
277 dir := filepath.Join(t.TempDir(), "gordon")
278 require.NoError(t, EnsureDir(dir))
279 realDir := t.TempDir()
280 real, err := net.ListenUnix("unix", &net.UnixAddr{Name: filepath.Join(realDir, "real.sock"), Net: "unix"})
281 require.NoError(t, err)
282 t.Cleanup(func() { _ = real.Close() })
283
284 require.NoError(t, os.Symlink(filepath.Join(realDir, "real.sock"), SocketPath(dir)))
285
286 _, _, err = Listen(dir)
287 require.Error(t, err)
288 assert.ErrorIs(t, err, ErrUnsafePath)
289}
290
291func TestValidateSocketRejectsGroupAccessibleSocket(t *testing.T) {
292 dir := t.TempDir()
293 ln, _, err := Listen(dir)
294 require.NoError(t, err)
295 t.Cleanup(func() { _ = ln.Close() })
296
297 require.NoError(t, os.Chmod(SocketPath(dir), 0o660))
298 err = ValidateSocket(SocketPath(dir))
299 require.Error(t, err)
300 assert.ErrorIs(t, err, ErrUnsafePath)
301}
302
303func TestRemoveOwnedSocketRemovesBoundSocket(t *testing.T) {
304 dir := filepath.Join(t.TempDir(), "gordon")
305 ln, info, err := Listen(dir)
306 require.NoError(t, err)
307 t.Cleanup(func() { _ = ln.Close() })
308
309 require.NoError(t, RemoveOwnedSocket(SocketPath(dir), info))
310 assert.NoFileExists(t, SocketPath(dir))
311}
312
313func TestRemoveOwnedSocketLeavesReplacement(t *testing.T) {
314 dir := filepath.Join(t.TempDir(), "gordon")
315 ln, info, err := Listen(dir)
316 require.NoError(t, err)
317 t.Cleanup(func() { _ = ln.Close() })
318
319 path := SocketPath(dir)
320 require.NoError(t, os.Remove(path))
321 require.NoError(t, os.WriteFile(path, []byte("replacement"), 0o600))
322
323 err = RemoveOwnedSocket(path, info)
324 require.Error(t, err)
325 assert.ErrorIs(t, err, ErrUnsafePath)
326 assert.FileExists(t, path)
327}
328
329func TestRemoveOwnedSocketIgnoresMissingPath(t *testing.T) {
330 assert.NoError(t, RemoveOwnedSocket(filepath.Join(t.TempDir(), "absent.sock"), nil))
331}
332
333func TestClientDirCandidatesOrderAndDedupe(t *testing.T) {
334 home := t.TempDir()
335 t.Setenv("HOME", home)
336
337 uid := strconv.Itoa(os.Getuid())
338 systemDir := filepath.Join("/run/user", uid, "gordon")
339
340 t.Run("xdg coincides with the systemd default", func(t *testing.T) {
341 t.Setenv("XDG_RUNTIME_DIR", filepath.Join("/run/user", uid))
342 assert.Equal(t, []string{systemDir, filepath.Join(home, ".gordon", "run")}, ClientDirCandidates())
343 })
344
345 t.Run("xdg differs from the systemd default", func(t *testing.T) {
346 xdg := t.TempDir()
347 t.Setenv("XDG_RUNTIME_DIR", xdg)
348 assert.Equal(t, []string{
349 filepath.Join(xdg, "gordon"),
350 systemDir,
351 filepath.Join(home, ".gordon", "run"),
352 }, ClientDirCandidates())
353 })
354}
355
356func TestDialContextRejectsPeerUIDMismatchBeforeUse(t *testing.T) {
357 dir := filepath.Join(t.TempDir(), "gordon")
358 ln, _, err := Listen(dir)
359 require.NoError(t, err)
360 t.Cleanup(func() { _ = ln.Close() })
361
362 accepted := make(chan net.Conn, 1)
363 go func() {
364 conn, acceptErr := ln.Accept()
365 if acceptErr == nil {
366 accepted <- conn
367 }
368 }()
369
370 _, err = dialContextForUID(context.Background(), SocketPath(dir), uint32(os.Geteuid()+1))
371 require.Error(t, err)
372 assert.ErrorIs(t, err, ErrPeerUIDMismatch)
373
374 select {
375 case conn := <-accepted:
376 defer conn.Close()
377 require.NoError(t, conn.SetReadDeadline(time.Now().Add(time.Second)))
378 buf := make([]byte, 1)
379 n, readErr := conn.Read(buf)
380 assert.Zero(t, n, "no request bytes may be sent before peer authentication")
381 assert.Error(t, readErr)
382 case <-time.After(time.Second):
383 t.Fatal("server did not accept authenticated connection attempt")
384 }
385}
386
387func TestListenRefusesForeignOwnedSocketDirectory(t *testing.T) {
388 if os.Geteuid() != 0 {
389 t.Skip("requires root to change directory ownership")
390 }
391 dir := filepath.Join(t.TempDir(), "gordon")
392 require.NoError(t, os.Mkdir(dir, 0o700))
393 require.NoError(t, os.Chown(dir, 65534, 65534))
394
395 _, _, err := Listen(dir)
396 require.Error(t, err)
397}