v3.3.0 b164fe9

localadmin_test.go RawHistoryFolder

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}

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 7

Tags 106

↑↓ moveenter openesc close