diff --git a/apis/v1alpha1/validation.go b/apis/v1alpha1/validation.go index 9646919..10bb849 100644 --- a/apis/v1alpha1/validation.go +++ b/apis/v1alpha1/validation.go @@ -15,12 +15,22 @@ package v1alpha1 import ( + "bufio" "errors" "fmt" "net/mail" "strings" ) +var ( + defaultCLI = "gcloud" + invalidCommandOperators = map[rune]struct{}{ + '&': {}, + '|': {}, + '>': {}, + } +) + // ValidateIAMRequest checks if the IAMRequest is valid. func ValidateIAMRequest(r *IAMRequest) (retErr error) { for _, s := range r.ResourcePolicies { @@ -57,3 +67,43 @@ func ValidateIAMRequest(r *IAMRequest) (retErr error) { } return } + +// ValidateCLIRequest checks if the CLIRequest is valid. +func ValidateCLIRequest(r *CLIRequest) (retErr error) { + // Set default CLI + if r.CLI == "" { + r.CLI = defaultCLI + } + // TODO (#49): support other CLIs. + if r.CLI != defaultCLI { + retErr = errors.Join(retErr, fmt.Errorf("CLI %q is not supported", r.CLI)) + } + + // Check if the do commands are valid. + for _, c := range r.Do { + if err := checkCommand(c); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("do command %q is not valid: %w", c, err)) + } + } + + // Check if the cleanup commands are valid. + for _, c := range r.Cleanup { + if err := checkCommand(c); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("cleanup command %q is not valid: %w", c, err)) + } + } + return retErr +} + +func checkCommand(c string) (retErr error) { + scanner := bufio.NewScanner(strings.NewReader(c)) + for row := 1; scanner.Scan(); row++ { + line := scanner.Text() + for col, r := range line { + if _, ok := invalidCommandOperators[r]; ok { + retErr = errors.Join(retErr, fmt.Errorf("disallowed command character %q at %d:%d", r, row, col)) + } + } + } + return retErr +} diff --git a/apis/v1alpha1/validation_test.go b/apis/v1alpha1/validation_test.go index f6302dd..9148900 100644 --- a/apis/v1alpha1/validation_test.go +++ b/apis/v1alpha1/validation_test.go @@ -200,3 +200,93 @@ func TestValidateIAMRequest(t *testing.T) { }) } } + +func TestValidateCLIRequest(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + request *CLIRequest + wantErr string + }{ + { + name: "success", + request: &CLIRequest{ + CLI: "gcloud", + Do: []string{ + "run jobs execute my-job1", + "run jobs execute my-job2", + }, + Cleanup: []string{ + "run jobs executions delete my-execution1", + "run jobs executions delete my-execution2", + }, + }, + }, + { + name: "success_with_default_cli", + request: &CLIRequest{ + Do: []string{ + "run jobs execute my-job1", + "run jobs execute my-job2", + }, + Cleanup: []string{ + "run jobs executions delete my-execution1", + "run jobs executions delete my-execution2", + }, + }, + }, + { + name: "invalid_cli", + request: &CLIRequest{ + CLI: "aws", + Do: []string{ + "run jobs execute my-job", + }, + Cleanup: []string{ + "run jobs executions delete my-execution", + }, + }, + wantErr: `CLI "aws" is not supported`, + }, + { + name: "invalid_do_command", + request: &CLIRequest{ + Do: []string{ + `run +jobs execute my-job && rmdir dir`, + }, + Cleanup: []string{ + "run jobs executions delete my-execution", + }, + }, + wantErr: `disallowed command character '&' at 2:20 +disallowed command character '&' at 2:21`, + }, + { + name: "invalid_cleanup_command", + request: &CLIRequest{ + Do: []string{ + "run jobs execute my-job", + }, + Cleanup: []string{ + "storage cat gs://bucket/secrets.txt > my-file.txt", + }, + }, + wantErr: `disallowed command character '>' at 1:36`, + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotErr := ValidateCLIRequest(tc.request) + if diff := testutil.DiffErrString(gotErr, tc.wantErr); diff != "" { + t.Errorf("Process %s got unexpected error: %s", tc.name, diff) + } + }) + } +}