detached 584c131

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +62 -3
M internal/domain/env.go +17 -0
@@ -11,6 +11,7 @@ import (
1111 // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
1212 var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
1313 var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
14+var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
1415
1516 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
1617 // Returns a safe domain string suitable for filenames.
@@ -89,3 +90,19 @@ func ValidateEnvKey(key string) error {
8990 }
9091 return nil
9192 }
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 nil91 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) {
163163 })
164164 }
165165 }
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 (
4646 ErrConfigLoadFailed = errors.New("failed to load configuration")
4747
4848 // 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")
5253
5354 // Authentication errors
5455 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 errors48 // 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 errors54 // Authentication errors
54 ErrInvalidToken = errors.New("invalid token")55 ErrInvalidToken = errors.New("invalid token")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close