fix(domain): add ErrInvalidContainerName for better error semantics
Replace ErrInvalidEnvKey with dedicated ErrInvalidContainerName when validating container names. This provides clearer error context and distinguishes between invalid environment keys and invalid container names.
- commit
- 584c13189fe39e8c513242e46df394c982eab4c8
- author
- bnema · · 2026-01-31 22:20 UTC
- parent
- 8541735
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +62 -3
M internal/domain/env.go +17 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | |
| 12 | 12 | var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | |
| 13 | 13 | var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | |
| 14 | + | var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) | |
| 14 | 15 | ||
| 15 | 16 | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | |
| 16 | 17 | // Returns a safe domain string suitable for filenames. | |
| @@ -89,3 +90,19 @@ func ValidateEnvKey(key string) error { | |||
| 89 | 90 | } | |
| 90 | 91 | return nil | |
| 91 | 92 | } | |
| 93 | + | ||
| 94 | + | // ValidateContainerName validates a container name for attachment storage. | |
| 95 | + | // Container names can contain alphanumeric characters, hyphens, and underscores, | |
| 96 | + | // but must start with a letter. | |
| 97 | + | func ValidateContainerName(name string) error { | |
| 98 | + | if name == "" { | |
| 99 | + | return ErrInvalidContainerName | |
| 100 | + | } | |
| 101 | + | if strings.Contains(name, "..") || strings.ContainsAny(name, "/\\") { | |
| 102 | + | return ErrPathTraversal | |
| 103 | + | } | |
| 104 | + | if !containerNameRegex.MatchString(name) { | |
| 105 | + | return ErrInvalidContainerName | |
| 106 | + | } | |
| 107 | + | return nil | |
| 108 | + | } | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | 11 | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | ||
| 12 | var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | 12 | var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | ||
| 13 | var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | 13 | var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | ||
| 14 | + | var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) | |||
| 14 | 15 | ||||
| 15 | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | 16 | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | ||
| 16 | // Returns a safe domain string suitable for filenames. | 17 | // Returns a safe domain string suitable for filenames. | ||
| @@ -89,3 +90,19 @@ func ValidateEnvKey(key string) error { | |||||
| 89 | } | 90 | } | ||
| 90 | return nil | 91 | return nil | ||
| 91 | } | 92 | } | ||
| 93 | + | ||||
| 94 | + | // ValidateContainerName validates a container name for attachment storage. | |||
| 95 | + | // Container names can contain alphanumeric characters, hyphens, and underscores, | |||
| 96 | + | // but must start with a letter. | |||
| 97 | + | func ValidateContainerName(name string) error { | |||
| 98 | + | if name == "" { | |||
| 99 | + | return ErrInvalidContainerName | |||
| 100 | + | } | |||
| 101 | + | if strings.Contains(name, "..") || strings.ContainsAny(name, "/\\") { | |||
| 102 | + | return ErrPathTraversal | |||
| 103 | + | } | |||
| 104 | + | if !containerNameRegex.MatchString(name) { | |||
| 105 | + | return ErrInvalidContainerName | |||
| 106 | + | } | |||
| 107 | + | return nil | |||
| 108 | + | } | |||
M internal/domain/env_test.go +41 -0
| @@ -163,3 +163,44 @@ func TestValidateEnvKey(t *testing.T) { | |||
| 163 | 163 | }) | |
| 164 | 164 | } | |
| 165 | 165 | } | |
| 166 | + | ||
| 167 | + | func TestValidateContainerName(t *testing.T) { | |
| 168 | + | tests := []struct { | |
| 169 | + | name string | |
| 170 | + | container string | |
| 171 | + | wantErr bool | |
| 172 | + | wantErrType error | |
| 173 | + | }{ | |
| 174 | + | {name: "simple", container: "postgres", wantErr: false}, | |
| 175 | + | {name: "with hyphen", container: "gitea-postgres", wantErr: false}, | |
| 176 | + | {name: "with underscore", container: "gitea_postgres", wantErr: false}, | |
| 177 | + | {name: "complex real container", container: "gordon-git-example-com-gitea-postgres", wantErr: false}, | |
| 178 | + | {name: "starts with letter", container: "a-b", wantErr: false}, | |
| 179 | + | {name: "lowercase", container: "my-container", wantErr: false}, | |
| 180 | + | {name: "mixed case", container: "MyContainer-123", wantErr: false}, | |
| 181 | + | {name: "empty", container: "", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 182 | + | {name: "starts with number", container: "1container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 183 | + | {name: "starts with hyphen", container: "-container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 184 | + | {name: "starts with underscore", container: "_container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 185 | + | {name: "contains slash", container: "container/name", wantErr: true, wantErrType: ErrPathTraversal}, | |
| 186 | + | {name: "contains backslash", container: `container\name`, wantErr: true, wantErrType: ErrPathTraversal}, | |
| 187 | + | {name: "path traversal", container: "..", wantErr: true, wantErrType: ErrPathTraversal}, | |
| 188 | + | {name: "contains dot", container: "container.name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 189 | + | {name: "contains space", container: "container name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 190 | + | {name: "contains special char", container: "container$name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | for _, tt := range tests { | |
| 194 | + | t.Run(tt.name, func(t *testing.T) { | |
| 195 | + | err := ValidateContainerName(tt.container) | |
| 196 | + | if tt.wantErr { | |
| 197 | + | require.Error(t, err) | |
| 198 | + | if tt.wantErrType != nil { | |
| 199 | + | assert.ErrorIs(t, err, tt.wantErrType) | |
| 200 | + | } | |
| 201 | + | } else { | |
| 202 | + | assert.NoError(t, err) | |
| 203 | + | } | |
| 204 | + | }) | |
| 205 | + | } | |
| 206 | + | } | |
| @@ -163,3 +163,44 @@ func TestValidateEnvKey(t *testing.T) { | |||||
| 163 | }) | 163 | }) | ||
| 164 | } | 164 | } | ||
| 165 | } | 165 | } | ||
| 166 | + | ||||
| 167 | + | func TestValidateContainerName(t *testing.T) { | |||
| 168 | + | tests := []struct { | |||
| 169 | + | name string | |||
| 170 | + | container string | |||
| 171 | + | wantErr bool | |||
| 172 | + | wantErrType error | |||
| 173 | + | }{ | |||
| 174 | + | {name: "simple", container: "postgres", wantErr: false}, | |||
| 175 | + | {name: "with hyphen", container: "gitea-postgres", wantErr: false}, | |||
| 176 | + | {name: "with underscore", container: "gitea_postgres", wantErr: false}, | |||
| 177 | + | {name: "complex real container", container: "gordon-git-example-com-gitea-postgres", wantErr: false}, | |||
| 178 | + | {name: "starts with letter", container: "a-b", wantErr: false}, | |||
| 179 | + | {name: "lowercase", container: "my-container", wantErr: false}, | |||
| 180 | + | {name: "mixed case", container: "MyContainer-123", wantErr: false}, | |||
| 181 | + | {name: "empty", container: "", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 182 | + | {name: "starts with number", container: "1container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 183 | + | {name: "starts with hyphen", container: "-container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 184 | + | {name: "starts with underscore", container: "_container", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 185 | + | {name: "contains slash", container: "container/name", wantErr: true, wantErrType: ErrPathTraversal}, | |||
| 186 | + | {name: "contains backslash", container: `container\name`, wantErr: true, wantErrType: ErrPathTraversal}, | |||
| 187 | + | {name: "path traversal", container: "..", wantErr: true, wantErrType: ErrPathTraversal}, | |||
| 188 | + | {name: "contains dot", container: "container.name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 189 | + | {name: "contains space", container: "container name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 190 | + | {name: "contains special char", container: "container$name", wantErr: true, wantErrType: ErrInvalidContainerName}, | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | for _, tt := range tests { | |||
| 194 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 195 | + | err := ValidateContainerName(tt.container) | |||
| 196 | + | if tt.wantErr { | |||
| 197 | + | require.Error(t, err) | |||
| 198 | + | if tt.wantErrType != nil { | |||
| 199 | + | assert.ErrorIs(t, err, tt.wantErrType) | |||
| 200 | + | } | |||
| 201 | + | } else { | |||
| 202 | + | assert.NoError(t, err) | |||
| 203 | + | } | |||
| 204 | + | }) | |||
| 205 | + | } | |||
| 206 | + | } | |||
M internal/domain/errors.go +4 -3
| @@ -46,9 +46,10 @@ var ( | |||
| 46 | 46 | ErrConfigLoadFailed = errors.New("failed to load configuration") | |
| 47 | 47 | ||
| 48 | 48 | // Environment errors | |
| 49 | - | ErrEnvFileNotFound = errors.New("environment file not found") | |
| 50 | - | ErrSecretNotFound = errors.New("secret not found") | |
| 51 | - | ErrProviderNotFound = errors.New("secret provider not found") | |
| 49 | + | ErrEnvFileNotFound = errors.New("environment file not found") | |
| 50 | + | ErrSecretNotFound = errors.New("secret not found") | |
| 51 | + | ErrProviderNotFound = errors.New("secret provider not found") | |
| 52 | + | ErrInvalidContainerName = errors.New("invalid container name") | |
| 52 | 53 | ||
| 53 | 54 | // Authentication errors | |
| 54 | 55 | ErrInvalidToken = errors.New("invalid token") | |
| @@ -46,9 +46,10 @@ var ( | |||||
| 46 | ErrConfigLoadFailed = errors.New("failed to load configuration") | 46 | ErrConfigLoadFailed = errors.New("failed to load configuration") | ||
| 47 | 47 | ||||
| 48 | // Environment errors | 48 | // Environment errors | ||
| 49 | - | ErrEnvFileNotFound = errors.New("environment file not found") | 49 | + | ErrEnvFileNotFound = errors.New("environment file not found") |
| 50 | - | ErrSecretNotFound = errors.New("secret not found") | 50 | + | ErrSecretNotFound = errors.New("secret not found") |
| 51 | - | ErrProviderNotFound = errors.New("secret provider not found") | 51 | + | ErrProviderNotFound = errors.New("secret provider not found") |
| 52 | + | ErrInvalidContainerName = errors.New("invalid container name") | |||
| 52 | 53 | ||||
| 53 | // Authentication errors | 54 | // Authentication errors | ||
| 54 | ErrInvalidToken = errors.New("invalid token") | 55 | ErrInvalidToken = errors.New("invalid token") | ||