diff --git a/internal/sandbox/names.go b/internal/sandbox/names.go index e0b2e734..132abcf7 100644 --- a/internal/sandbox/names.go +++ b/internal/sandbox/names.go @@ -3,6 +3,7 @@ package sandbox import ( "fmt" "math/rand" + "regexp" ) var colors = []string{ @@ -19,6 +20,29 @@ var cities = []string{ "prague", "lisbon", "havana", "bogota", "nairobi", } +// MaxNameLength is the maximum allowed length for a sandbox name (DNS label limit). +const MaxNameLength = 63 + +// namePattern matches lowercase alphanumeric strings with optional hyphens +// between segments. Leading/trailing/consecutive hyphens are not allowed. +var namePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +// ValidateName checks that name contains only lowercase letters, numbers, and +// hyphens, with no leading/trailing/consecutive hyphens, and is at most 63 +// characters long (DNS label compatible). +func ValidateName(name string) error { + if name == "" { + return fmt.Errorf("sandbox name must not be empty") + } + if len(name) > MaxNameLength { + return fmt.Errorf("sandbox name %q exceeds maximum length of %d characters", name, MaxNameLength) + } + if !namePattern.MatchString(name) { + return fmt.Errorf("sandbox name %q is invalid: must contain only lowercase letters, numbers, and hyphens (no leading, trailing, or consecutive hyphens)", name) + } + return nil +} + // GenerateName returns a random name in the format "{color}-{city}". func GenerateName() string { color := colors[rand.Intn(len(colors))] diff --git a/internal/sandbox/names_test.go b/internal/sandbox/names_test.go new file mode 100644 index 00000000..50cb0913 --- /dev/null +++ b/internal/sandbox/names_test.go @@ -0,0 +1,51 @@ +package sandbox + +import ( + "strings" + "testing" +) + +func TestValidateName(t *testing.T) { + valid := []string{ + "foo", + "a", + "red-tokyo", + "my-sandbox-1", + "abc123", + "a-b-c", + } + for _, name := range valid { + if err := ValidateName(name); err != nil { + t.Errorf("ValidateName(%q) = %v; want nil", name, err) + } + } + + invalid := []struct { + name string + desc string + }{ + {"", "empty"}, + {"My-Sandbox", "uppercase"}, + {"foo_bar", "underscore"}, + {"-foo", "leading hyphen"}, + {"foo-", "trailing hyphen"}, + {"foo--bar", "consecutive hyphens"}, + {"foo.bar", "dot"}, + {"hello world", "space"}, + {strings.Repeat("a", 64), "too long"}, + } + for _, tc := range invalid { + if err := ValidateName(tc.name); err == nil { + t.Errorf("ValidateName(%q) [%s] = nil; want error", tc.name, tc.desc) + } + } +} + +func TestGenerateNameIsValid(t *testing.T) { + for i := 0; i < 100; i++ { + name := GenerateName() + if err := ValidateName(name); err != nil { + t.Fatalf("GenerateName() = %q; ValidateName returned %v", name, err) + } + } +} diff --git a/pkg/amika/service.go b/pkg/amika/service.go index dc7f1e03..f630f8a8 100644 --- a/pkg/amika/service.go +++ b/pkg/amika/service.go @@ -79,6 +79,8 @@ func (s *serviceImpl) CreateSandbox(_ context.Context, req CreateSandboxRequest) break } } + } else if err := sandbox.ValidateName(name); err != nil { + return Sandbox{}, fmt.Errorf("%w: %v", ErrInvalidArgument, err) } else if _, err := s.sandboxes.Get(name); err == nil { return Sandbox{}, fmt.Errorf("%w: sandbox %q already exists", ErrInvalidArgument, name) }