| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package ndexec |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "runtime" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/stretchr/testify/assert" |
| 15 | "github.com/stretchr/testify/require" |
| 16 | ) |
| 17 | |
| 18 | func TestRunner_run(t *testing.T) { |
| 19 | if runtime.GOOS == "windows" { |
| 20 | t.Skip("uses sh scripts") |
| 21 | } |
| 22 | |
| 23 | tmp := t.TempDir() |
| 24 | |
| 25 | writeExe := func(path, body string) { |
| 26 | require.NoError(t, os.WriteFile(path, []byte(body), 0o755), "write %s", path) |
| 27 | } |
| 28 | |
| 29 | // Target scripts the helper will exec into. |
| 30 | echoArgs := filepath.Join(tmp, "echoargs.sh") |
| 31 | writeExe(echoArgs, `#!/bin/sh |
| 32 | printf '%s|' "$@" |
| 33 | echo |
| 34 | `) |
| 35 | |
| 36 | longErr := filepath.Join(tmp, "longerr.sh") |
| 37 | long := strings.Repeat("x", 9000) // > stderrLimit |
| 38 | writeExe(longErr, `#!/bin/sh |
| 39 | printf '`+long+`' 1>&2 |
| 40 | exit 17 |
| 41 | `) |
| 42 | |
| 43 | sleeper := filepath.Join(tmp, "sleep.sh") |
| 44 | writeExe(sleeper, `#!/bin/sh |
| 45 | sleep "$1" |
| 46 | `) |
| 47 | |
| 48 | // Fake helper (acts like nd-run/ndsudo): replaces itself with the target. |
| 49 | helper := filepath.Join(tmp, "helper.sh") |
| 50 | writeExe(helper, `#!/bin/sh |
| 51 | exec "$@" |
| 52 | `) |
| 53 | |
| 54 | type tc struct { |
| 55 | helperPath string |
| 56 | argv []string |
| 57 | timeout time.Duration |
| 58 | wantOut string |
| 59 | wantErr bool |
| 60 | errContains []string |
| 61 | check func(t *testing.T, out []byte, err error) |
| 62 | } |
| 63 | |
| 64 | tests := map[string]tc{ |
| 65 | "success_echo_args": { |
| 66 | helperPath: helper, |
| 67 | argv: []string{echoArgs, `a b`, `c"d`}, |
| 68 | timeout: 15 * time.Second, |
| 69 | wantOut: "a b|c\"d|\n", |
| 70 | }, |
| 71 | "nonzero_with_trimmed_stderr": { |
| 72 | helperPath: helper, |
| 73 | argv: []string{longErr}, |
| 74 | timeout: 5 * time.Second, |
| 75 | wantErr: true, |
| 76 | errContains: []string{"stderr:", "truncated"}, |
| 77 | }, |
| 78 | "timeout": { |
| 79 | helperPath: helper, |
| 80 | argv: []string{sleeper, "2"}, |
| 81 | timeout: 200 * time.Millisecond, |
| 82 | wantErr: true, |
| 83 | errContains: []string{"deadline"}, |
| 84 | check: func(t *testing.T, _ []byte, err error) { |
| 85 | // Either errors.Is(err, context.DeadlineExceeded) or message contains it. |
| 86 | assert.ErrorIs(t, err, context.DeadlineExceeded) |
| 87 | }, |
| 88 | }, |
| 89 | "helper_missing": { |
| 90 | helperPath: filepath.Join(tmp, "missing", "helper.sh"), |
| 91 | argv: []string{echoArgs}, |
| 92 | timeout: time.Second, |
| 93 | wantErr: true, |
| 94 | errContains: []string{"no such file", "helper.sh"}, |
| 95 | }, |
| 96 | } |
| 97 | |
| 98 | r := &runner{} // we pass helperPath directly to run() |
| 99 | |
| 100 | for name, tt := range tests { |
| 101 | t.Run(name, func(t *testing.T) { |
| 102 | out, _, _, err := r.run(nil, tt.timeout, "", tt.helperPath, "RunTest", nil, tt.argv...) |
| 103 | |
| 104 | if tt.wantErr { |
| 105 | require.Error(t, err) |
| 106 | for _, frag := range tt.errContains { |
| 107 | assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(frag)) |
| 108 | } |
| 109 | } else { |
| 110 | require.NoError(t, err) |
| 111 | if tt.wantOut != "" { |
| 112 | assert.Equal(t, tt.wantOut, string(out)) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | if tt.check != nil { |
| 117 | tt.check(t, out, err) |
| 118 | } |
| 119 | }) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func TestRunDirect(t *testing.T) { |
| 124 | if runtime.GOOS == "windows" { |
| 125 | t.Skip("uses sh scripts") |
| 126 | } |
| 127 | |
| 128 | tmp := t.TempDir() |
| 129 | |
| 130 | writeExe := func(path, body string) { |
| 131 | require.NoError(t, os.WriteFile(path, []byte(body), 0o755)) |
| 132 | } |
| 133 | |
| 134 | echoArgs := filepath.Join(tmp, "echoargs.sh") |
| 135 | writeExe(echoArgs, "#!/bin/sh\nprintf '%s|' \"$@\"\necho\n") |
| 136 | |
| 137 | stderrScript := filepath.Join(tmp, "stderr.sh") |
| 138 | writeExe(stderrScript, "#!/bin/sh\necho 'some error' 1>&2\nexit 1\n") |
| 139 | |
| 140 | sleeper := filepath.Join(tmp, "sleep.sh") |
| 141 | writeExe(sleeper, "#!/bin/sh\nsleep 2\n") |
| 142 | |
| 143 | t.Run("success", func(t *testing.T) { |
| 144 | out, err := RunDirect(nil, time.Second, echoArgs, "hello", "world") |
| 145 | require.NoError(t, err) |
| 146 | assert.Equal(t, "hello|world|\n", string(out)) |
| 147 | }) |
| 148 | |
| 149 | t.Run("non-zero exit with stderr", func(t *testing.T) { |
| 150 | _, err := RunDirect(nil, time.Second, stderrScript) |
| 151 | require.Error(t, err) |
| 152 | assert.Contains(t, err.Error(), "execution failed") |
| 153 | assert.Contains(t, err.Error(), "some error") |
| 154 | }) |
| 155 | |
| 156 | t.Run("timeout", func(t *testing.T) { |
| 157 | _, err := RunDirect(nil, 200*time.Millisecond, sleeper) |
| 158 | require.Error(t, err) |
| 159 | assert.Contains(t, err.Error(), "execution failed") |
| 160 | }) |
| 161 | |
| 162 | t.Run("binary not found", func(t *testing.T) { |
| 163 | _, err := RunDirect(nil, time.Second, filepath.Join(tmp, "nonexistent")) |
| 164 | require.Error(t, err) |
| 165 | assert.Contains(t, err.Error(), "execution failed") |
| 166 | }) |
| 167 | |
| 168 | t.Run("long stderr truncated", func(t *testing.T) { |
| 169 | longStderr := filepath.Join(tmp, "longstderr.sh") |
| 170 | writeExe(longStderr, "#!/bin/sh\nprintf '"+strings.Repeat("x", 9000)+"' 1>&2\nexit 1\n") |
| 171 | |
| 172 | _, err := RunDirect(nil, 5*time.Second, longStderr) |
| 173 | require.Error(t, err) |
| 174 | assert.Contains(t, err.Error(), "truncated") |
| 175 | }) |
| 176 | } |
| 177 | |
| 178 | func TestRunDirectWithOptionsUsageContext(t *testing.T) { |
| 179 | if runtime.GOOS == "windows" { |
| 180 | t.Skip("uses sh scripts") |
| 181 | } |
| 182 | |
| 183 | tmp := t.TempDir() |
| 184 | workdir := filepath.Join(tmp, "subdir") |
| 185 | require.NoError(t, os.Mkdir(workdir, 0o755)) |
| 186 | |
| 187 | writeExe := func(path, body string) { |
| 188 | require.NoError(t, os.WriteFile(path, []byte(body), 0o755)) |
| 189 | } |
| 190 | |
| 191 | script := filepath.Join(tmp, "envpwd.sh") |
| 192 | writeExe(script, "#!/bin/sh\nprintf 'PWD=%s\\nFOO=%s\\n' \"$PWD\" \"$FOO\"\n") |
| 193 | |
| 194 | sleeper := filepath.Join(tmp, "sleep.sh") |
| 195 | writeExe(sleeper, "#!/bin/sh\nsleep 2\n") |
| 196 | |
| 197 | tests := map[string]struct { |
| 198 | timeout time.Duration |
| 199 | opts RunOptions |
| 200 | binPath string |
| 201 | args []string |
| 202 | assert func(*testing.T, []byte, string, ResourceUsage, error) |
| 203 | }{ |
| 204 | "honors working directory and explicit environment": { |
| 205 | timeout: time.Second, |
| 206 | opts: RunOptions{ |
| 207 | Dir: workdir, |
| 208 | Env: []string{"FOO=bar"}, |
| 209 | }, |
| 210 | binPath: script, |
| 211 | assert: func(t *testing.T, out []byte, cmd string, usage ResourceUsage, err error) { |
| 212 | t.Helper() |
| 213 | require.NoError(t, err) |
| 214 | assert.Contains(t, cmd, script) |
| 215 | assert.Contains(t, string(out), "\nFOO=bar\n") |
| 216 | assert.Contains(t, string(out), "PWD=") |
| 217 | assert.True(t, usage.User >= 0) |
| 218 | assert.True(t, usage.System >= 0) |
| 219 | }, |
| 220 | }, |
| 221 | "timeout is propagated through the direct helper": { |
| 222 | timeout: 100 * time.Millisecond, |
| 223 | opts: RunOptions{}, |
| 224 | binPath: sleeper, |
| 225 | assert: func(t *testing.T, _ []byte, _ string, _ ResourceUsage, err error) { |
| 226 | t.Helper() |
| 227 | require.Error(t, err) |
| 228 | assert.ErrorIs(t, err, context.DeadlineExceeded) |
| 229 | }, |
| 230 | }, |
| 231 | } |
| 232 | |
| 233 | for name, tc := range tests { |
| 234 | t.Run(name, func(t *testing.T) { |
| 235 | out, cmd, usage, err := RunDirectWithOptionsUsageContext(context.Background(), nil, tc.timeout, tc.opts, tc.binPath, tc.args...) |
| 236 | tc.assert(t, out, cmd, usage, err) |
| 237 | }) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func TestFindBinary(t *testing.T) { |
| 242 | tmp := t.TempDir() |
| 243 | |
| 244 | binPath := filepath.Join(tmp, "testbin") |
| 245 | require.NoError(t, os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o755)) |
| 246 | |
| 247 | t.Run("found in default paths", func(t *testing.T) { |
| 248 | path, err := FindBinary( |
| 249 | []string{"nonexistent-binary-12345"}, |
| 250 | []string{filepath.Join(tmp, "missing"), binPath}, |
| 251 | ) |
| 252 | require.NoError(t, err) |
| 253 | assert.Equal(t, binPath, path) |
| 254 | }) |
| 255 | |
| 256 | t.Run("not found anywhere", func(t *testing.T) { |
| 257 | _, err := FindBinary( |
| 258 | []string{"nonexistent-binary-12345"}, |
| 259 | []string{filepath.Join(tmp, "also-missing")}, |
| 260 | ) |
| 261 | require.Error(t, err) |
| 262 | assert.Contains(t, err.Error(), "executable not found") |
| 263 | }) |
| 264 | |
| 265 | t.Run("skips directories", func(t *testing.T) { |
| 266 | dirPath := filepath.Join(tmp, "adir") |
| 267 | require.NoError(t, os.Mkdir(dirPath, 0o755)) |
| 268 | |
| 269 | _, err := FindBinary( |
| 270 | []string{"nonexistent-binary-12345"}, |
| 271 | []string{dirPath}, |
| 272 | ) |
| 273 | require.Error(t, err) |
| 274 | }) |
| 275 | |
| 276 | t.Run("nil names searches only default paths", func(t *testing.T) { |
| 277 | path, err := FindBinary(nil, []string{binPath}) |
| 278 | require.NoError(t, err) |
| 279 | assert.Equal(t, binPath, path) |
| 280 | }) |
| 281 | |
| 282 | t.Run("nil names not found", func(t *testing.T) { |
| 283 | _, err := FindBinary(nil, []string{filepath.Join(tmp, "missing")}) |
| 284 | require.Error(t, err) |
| 285 | assert.Contains(t, err.Error(), "executable not found") |
| 286 | }) |
| 287 | |
| 288 | t.Run("both nil", func(t *testing.T) { |
| 289 | _, err := FindBinary(nil, nil) |
| 290 | require.Error(t, err) |
| 291 | }) |
| 292 | } |
| 293 | |
| 294 | func TestRunUnprivilegedWithOptionsCmdWorkingDir(t *testing.T) { |
| 295 | if runtime.GOOS == "windows" { |
| 296 | t.Skip("uses sh scripts") |
| 297 | } |
| 298 | |
| 299 | tmp := t.TempDir() |
| 300 | workdir := filepath.Join(tmp, "subdir") |
| 301 | require.NoError(t, os.Mkdir(workdir, 0o755)) |
| 302 | script := filepath.Join(tmp, "pwd.sh") |
| 303 | require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\npwd\n"), 0o755)) |
| 304 | |
| 305 | helper := filepath.Join(tmp, "helper.sh") |
| 306 | require.NoError(t, os.WriteFile(helper, []byte("#!/bin/sh\nexec \"$@\"\n"), 0o755)) |
| 307 | |
| 308 | orig := defaultRunner.ndRunPath |
| 309 | defaultRunner.ndRunPath = helper |
| 310 | defer func() { defaultRunner.ndRunPath = orig }() |
| 311 | |
| 312 | opts := RunOptions{Dir: workdir} |
| 313 | out, cmd, err := RunUnprivilegedWithOptionsCmd(nil, time.Second, opts, script) |
| 314 | require.NoError(t, err) |
| 315 | assert.Contains(t, cmd, script) |
| 316 | assert.Equal(t, workdir+"\n", string(out)) |
| 317 | } |
| 318 | |
| 319 | func TestRunUnprivilegedWithOptionsUsage(t *testing.T) { |
| 320 | if runtime.GOOS == "windows" { |
| 321 | t.Skip("uses sh scripts") |
| 322 | } |
| 323 | |
| 324 | tmp := t.TempDir() |
| 325 | script := filepath.Join(tmp, "noop.sh") |
| 326 | require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\nprintf foo"), 0o755)) |
| 327 | |
| 328 | helper := filepath.Join(tmp, "helper.sh") |
| 329 | require.NoError(t, os.WriteFile(helper, []byte("#!/bin/sh\nexec \"$@\"\n"), 0o755)) |
| 330 | |
| 331 | orig := defaultRunner.ndRunPath |
| 332 | defaultRunner.ndRunPath = helper |
| 333 | defer func() { defaultRunner.ndRunPath = orig }() |
| 334 | |
| 335 | opts := RunOptions{} |
| 336 | out, cmd, usage, err := RunUnprivilegedWithOptionsUsage(nil, time.Second, opts, script) |
| 337 | require.NoError(t, err) |
| 338 | assert.Contains(t, cmd, script) |
| 339 | assert.Equal(t, "foo", string(out)) |
| 340 | assert.True(t, usage.User >= 0) |
| 341 | assert.True(t, usage.System >= 0) |
| 342 | } |