Files

182 lines
6.4 KiB
Go

//nolint:testpackage
package rpc
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
"github.com/cirruslabs/tart-guest-agent/pkg/v1"
"github.com/stretchr/testify/require"
)
func TestValidateExecWrapper(t *testing.T) {
notExecutable := filepath.Join(t.TempDir(), "not-executable")
require.NoError(t, os.WriteFile(notExecutable, nil, 0o600))
for _, argv := range [][]string{
{""}, {"sh"}, {"/missing/tart-exec-wrapper"}, {t.TempDir()},
{notExecutable}, {execTestShell, "bad\x00argument"},
} {
require.Error(t, ValidateExecWrapper(argv), "%q", argv)
_, err := New(nil, argv...)
require.Error(t, err)
}
require.NoError(t, ValidateExecWrapper(nil))
require.NoError(t, ValidateExecWrapper([]string{execTestShell, "", "literal,$value"}))
}
func TestValidateExecWrapperRequiresApplicableExecutePermission(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root can execute a file with any execute bit set")
}
path := filepath.Join(t.TempDir(), "other-executable")
contents, err := os.ReadFile("/usr/bin/true")
require.NoError(t, err)
require.NoError(t, os.WriteFile(path, contents, 0o600)) // #nosec G703 -- Destination is inside t.TempDir.
require.NoError(t, os.Chmod(path, 0o001)) // #nosec G302 -- Tests inapplicable execute permission.
// The current user owns this file, so its other-execute bit does not apply.
// #nosec G204 -- Path is an owned permission-test fixture.
require.ErrorIs(t, exec.CommandContext(t.Context(), path).Run(), os.ErrPermission)
require.ErrorIs(t, ValidateExecWrapper([]string{path}), os.ErrPermission)
_, err = New(nil, path)
require.ErrorIs(t, err, os.ErrPermission)
}
func TestExecWrapperPreservesArgumentsAndOverrides(t *testing.T) {
workdir := t.TempDir()
canonicalWorkdir, err := filepath.EvalSymlinks(workdir)
require.NoError(t, err)
wrapper := writeExecWrapper(t, `printf '%s\n' "$1"
shift
exec "$@"
`)
prefix := []string{wrapper, "fixed,argument $HOME; $(false)"}
rpc, err := New(nil, prefix...)
require.NoError(t, err)
prefix[0] = "/missing/changed-after-startup"
prefix[1] = "changed-after-startup"
_, stream, result := startExecTestWithRPC(t, rpc, &v1.ExecRequest_Command{
Name: execTestShell,
Args: []string{"-c", `printf '%s\n' "$PWD" "$WRAPPER_TEST_VALUE" "$1"; id -u`, "test", "literal; $(false)"},
Env: map[string]string{"WRAPPER_TEST_VALUE": "value with spaces"},
Workdir: workdir,
User: strconv.Itoa(os.Getuid()),
})
output, exitCode := collectExecWrapperResult(t, stream)
require.Zero(t, exitCode)
require.Equal(t, fmt.Sprintf("fixed,argument $HOME; $(false)\n%s\nvalue with spaces\nliteral; $(false)\n%d\n",
canonicalWorkdir, os.Getuid()), output)
require.NoError(t, receiveExecResult(t, result))
}
func TestExecWrapperDeniesEveryExecutionMode(t *testing.T) {
const detachedMode = "detached"
for _, mode := range []string{"normal", "interactive", "pty", detachedMode} {
t.Run(mode, func(t *testing.T) {
directory := t.TempDir()
receipt := filepath.Join(directory, "wrapper-ran")
target := filepath.Join(directory, "target-ran")
wrapper := writeExecWrapper(t, `printf denied > "$WRAPPER_RECEIPT"
exit 86
`)
rpc, err := New(nil, wrapper)
require.NoError(t, err)
_, stream, result := startExecTestWithRPC(t, rpc, &v1.ExecRequest_Command{
Name: execTestShell,
Args: []string{"-c", `printf unrestricted > "$TARGET_RECEIPT"`},
Env: map[string]string{"WRAPPER_RECEIPT": receipt, "TARGET_RECEIPT": target},
Interactive: mode == "interactive",
Tty: mode == "pty",
Detach: mode == detachedMode,
})
_, exitCode := collectExecWrapperResult(t, stream)
if mode == detachedMode {
require.Zero(t, exitCode) // Detached success acknowledges process creation.
} else {
require.EqualValues(t, 86, exitCode)
}
require.NoError(t, receiveExecResult(t, result))
require.Eventually(t, func() bool { _, err := os.Stat(receipt); return err == nil },
execTestTimeout, 10*time.Millisecond)
_, err = os.Stat(target)
require.ErrorIs(t, err, os.ErrNotExist)
})
}
}
func TestExecWrapperDisappearingDoesNotRunTarget(t *testing.T) {
wrapper := writeExecWrapper(t, "exec \"$@\"\n")
rpc, err := New(nil, wrapper)
require.NoError(t, err)
require.NoError(t, os.Remove(wrapper))
_, stream, result := startExecTestWithRPC(t, rpc, &v1.ExecRequest_Command{Name: "/usr/bin/true"})
response := receiveExecResponse(t, stream)
require.Nil(t, response.GetStarted())
require.EqualValues(t, execRuntimeFailureExitCode, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
}
func TestExecWrapperSignalsProcessGroup(t *testing.T) {
for _, tty := range []bool{false, true} {
t.Run(fmt.Sprintf("tty=%t", tty), func(t *testing.T) {
wrapper := writeExecWrapper(t, "exec \"$@\"\n")
rpc, err := New(nil, wrapper)
require.NoError(t, err)
_, stream, result := startExecTestWithRPC(t, rpc, &v1.ExecRequest_Command{
Name: execTestShell,
Args: []string{"-c", "sleep 30 & printf ready; wait"},
Tty: tty,
})
started := receiveExecResponse(t, stream).GetStarted()
require.NotNil(t, started)
require.Equal(t, []byte("ready"), receiveExecResponse(t, stream).GetStandardOutput().GetData())
_, err = rpc.Signal(context.Background(), &v1.SignalRequest{
ExecId: started.GetExecId(),
Signal: v1.SignalRequest_SIGNAL_SIGTERM,
})
require.NoError(t, err)
response := receiveExecResponse(t, stream)
require.EqualValues(t, signalExitCodeOffset+syscall.SIGTERM, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
})
}
}
func writeExecWrapper(t *testing.T, body string) string {
path := filepath.Join(t.TempDir(), "wrapper")
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o600))
require.NoError(t, os.Chmod(path, 0o700)) // #nosec G302 -- This fixture must be executable.
return path
}
func collectExecWrapperResult(t *testing.T, stream *execTestStream) (string, int32) {
require.NotNil(t, receiveExecResponse(t, stream).GetStarted())
var output strings.Builder
for {
response := receiveExecResponse(t, stream)
switch value := response.GetType().(type) {
case *v1.ExecResponse_StandardOutput:
output.Write(value.StandardOutput.GetData())
case *v1.ExecResponse_StandardError:
output.Write(value.StandardError.GetData())
case *v1.ExecResponse_Exit_:
return output.String(), value.Exit.GetCode()
default:
t.Fatalf("unexpected exec response %T", value)
}
}
}