v0.14.0 815fb98

cli_shell_unix_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//go:build !windows
4
5package app
6
7import (
8	"context"
9	"errors"
10	"io/fs"
11	"os"
12	"path/filepath"
13	"strconv"
14	"strings"
15	"syscall"
16	"testing"
17	"time"
18
19	"github.com/stretchr/testify/assert"
20	"github.com/stretchr/testify/require"
21)
22
23// processGone reports whether pid is gone (a zombie that nobody reaped counts).
24func processGone(pid int) bool {
25	if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) {
26		return true
27	}
28	stat, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
29	if err != nil {
30		return false
31	}
32	_, after, _ := strings.Cut(string(stat), ") ")
33	return strings.HasPrefix(after, "Z")
34}
35
36// readPid waits for the pid file written by a grandchild.
37func readPid(t *testing.T, path string) int {
38	t.Helper()
39	var pid int
40	require.Eventually(t, func() bool {
41		data, err := os.ReadFile(path)
42		if err != nil {
43			return false
44		}
45		pid, err = strconv.Atoi(strings.TrimSpace(string(data)))
46		return err == nil && pid > 0
47	}, 5*time.Second, 10*time.Millisecond)
48	return pid
49}
50
51func TestRunShellKillsTheWholeProcessTreeOnTimeoutAndCancel(t *testing.T) {
52	requireNoTerminal(t)
53	for name, tc := range map[string]struct {
54		ctx     func(t *testing.T) context.Context
55		timeout time.Duration
56		want    string
57	}{
58		"timeout": {func(t *testing.T) context.Context { return t.Context() }, 700 * time.Millisecond, "timed out after 700ms"},
59		"cancel": {func(t *testing.T) context.Context {
60			ctx, cancel := context.WithCancel(t.Context())
61			time.AfterFunc(700*time.Millisecond, cancel)
62			return ctx
63		}, time.Minute, "interrupted"},
64	} {
65		t.Run(name, func(t *testing.T) {
66			pidFile := filepath.Join(t.TempDir(), "pid")
67			// sh starts a grandchild, records its pid and waits for it
68			line := "sleep 30 & echo $! > '" + pidFile + "'; wait"
69			_, err := runShell(tc.ctx(t), line, tc.timeout)
70			require.Error(t, err)
71			assert.Equal(t, tc.want, err.Error())
72
73			pid := readPid(t, pidFile)
74			require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond,
75				"the grandchild %d must not outlive the command", pid)
76		})
77	}
78}
79
80func TestRunShellStopsOnSIGTERM(t *testing.T) {
81	requireNoTerminal(t)
82	pidFile := filepath.Join(t.TempDir(), "pid")
83	line := "sleep 30 & echo $! > '" + pidFile + "'; wait"
84	errc := make(chan error, 1)
85	go func() {
86		_, err := runShell(t.Context(), line, time.Minute)
87		errc <- err
88	}()
89	pid := readPid(t, pidFile)
90	// the signal is delivered to the test process itself: runShell catches it
91	require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGTERM))
92	select {
93	case err := <-errc:
94		require.Error(t, err)
95		assert.Equal(t, "interrupted", err.Error())
96	case <-time.After(10 * time.Second):
97		t.Fatal("runShell did not stop on SIGTERM")
98	}
99	require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond)
100}
101
102// fakeInfo is a file whose owner and mode are chosen by the test.
103type fakeInfo struct {
104	fs.FileInfo
105	mode fs.FileMode
106	uid  uint32
107}
108
109func (f fakeInfo) Mode() fs.FileMode { return f.mode }
110func (f fakeInfo) Sys() any          { return &syscall.Stat_t{Uid: f.uid} }
111
112func TestCheckPrivate(t *testing.T) {
113	dir := t.TempDir() // owned by the test user, mode 0700
114	path := filepath.Join(dir, "config")
115	require.NoError(t, os.WriteFile(path, nil, 0o600))
116	real, err := os.Stat(path)
117	require.NoError(t, err)
118	me := os.Geteuid()
119
120	tests := []struct {
121		name string
122		mode fs.FileMode
123		uid  int // owner of the file
124		euid int
125		want string // error text, "" for accepted
126	}{
127		{"own private file", 0o600, me, me, ""},
128		{"file of another user", 0o600, me + 1, me, "is owned by another user"},
129		{"file owned by root, not me", 0o600, 0, me + 1, "is owned by another user"},
130		{"group writable", 0o620, me, me, "is writable by others"},
131		{"world writable", 0o602, me, me, "is writable by others"},
132		{"group readable", 0o640, me, me, ""},
133		// the directory is the real one: owned by me
134		{"directory of another user", 0o600, me + 1, me + 1, "config directory"},
135	}
136	for _, tt := range tests {
137		t.Run(tt.name, func(t *testing.T) {
138			if tt.want == "config directory" && me == 0 {
139				t.Skip("root owns every directory it creates")
140			}
141			err := checkPrivate(path, fakeInfo{FileInfo: real, mode: tt.mode, uid: uint32(tt.uid)}, tt.euid)
142			if tt.want == "" {
143				assert.NoError(t, err)
144				return
145			}
146			require.Error(t, err)
147			assert.Contains(t, err.Error(), tt.want)
148		})
149	}
150
151	t.Run("directory owned by root is fine", func(t *testing.T) {
152		// /tmp and friends are root's; use a root-owned directory when there is one
153		fi, err := os.Stat("/")
154		require.NoError(t, err)
155		if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 || fi.Mode().Perm()&0o022 != 0 {
156			t.Skip("/ is not a private root directory here")
157		}
158		assert.NoError(t, checkPrivateDir("/", me+1))
159	})
160}

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 45

↑↓ moveenter openesc close