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}