detached b467aa8

Harden env storage and admin cache handling

commit
b467aa8364042442c44f9a6f906be4f6004b98c1
author
brice · · 2026-03-05 21:45 UTC
parent
ec63ba9
signature
unsigned
20 files · +201 -169
M internal/adapters/in/cli/controlplane_resolver_test.go +2 -0
@@ -30,6 +30,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
3030
3131 [auth]
3232 enabled = true
33+secrets_backend = "unsafe"
3334 `), 0o600)
3435 if err != nil {
3536 t.Fatalf("failed to write config: %v", err)
@@ -70,6 +71,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
7071
7172 [auth]
7273 enabled = false
74+secrets_backend = "unsafe"
7375 `), 0o600)
7476 if err != nil {
7577 t.Fatalf("failed to write config: %v", err)
@@ -30,6 +30,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
30 30
31 [auth]31 [auth]
32 enabled = true32 enabled = true
33+secrets_backend = "unsafe"
33 `), 0o600)34 `), 0o600)
34 if err != nil {35 if err != nil {
35 t.Fatalf("failed to write config: %v", err)36 t.Fatalf("failed to write config: %v", err)
@@ -70,6 +71,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
70 71
71 [auth]72 [auth]
72 enabled = false73 enabled = false
74+secrets_backend = "unsafe"
73 `), 0o600)75 `), 0o600)
74 if err != nil {76 if err != nil {
75 t.Fatalf("failed to write config: %v", err)77 t.Fatalf("failed to write config: %v", err)
M internal/adapters/in/http/admin/middleware.go +4 -0
@@ -33,6 +33,8 @@ func AuthMiddleware(
3333 // Check global rate limit
3434 if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
3535 w.Header().Set("Content-Type", "application/json")
36+ w.Header().Set("Cache-Control", "no-store")
37+ w.Header().Set("Pragma", "no-cache")
3638 w.Header().Set("Retry-After", "1")
3739 w.WriteHeader(http.StatusTooManyRequests)
3840 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
@@ -44,6 +46,8 @@ func AuthMiddleware(
4446 ip := middleware.GetClientIP(r, trustedNets)
4547 if !ipLimiter.Allow(ctx, "ip:"+ip) {
4648 w.Header().Set("Content-Type", "application/json")
49+ w.Header().Set("Cache-Control", "no-store")
50+ w.Header().Set("Pragma", "no-cache")
4751 w.Header().Set("Retry-After", "1")
4852 w.WriteHeader(http.StatusTooManyRequests)
4953 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
@@ -33,6 +33,8 @@ func AuthMiddleware(
33 // Check global rate limit33 // Check global rate limit
34 if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {34 if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
35 w.Header().Set("Content-Type", "application/json")35 w.Header().Set("Content-Type", "application/json")
36+ w.Header().Set("Cache-Control", "no-store")
37+ w.Header().Set("Pragma", "no-cache")
36 w.Header().Set("Retry-After", "1")38 w.Header().Set("Retry-After", "1")
37 w.WriteHeader(http.StatusTooManyRequests)39 w.WriteHeader(http.StatusTooManyRequests)
38 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})40 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
@@ -44,6 +46,8 @@ func AuthMiddleware(
44 ip := middleware.GetClientIP(r, trustedNets)46 ip := middleware.GetClientIP(r, trustedNets)
45 if !ipLimiter.Allow(ctx, "ip:"+ip) {47 if !ipLimiter.Allow(ctx, "ip:"+ip) {
46 w.Header().Set("Content-Type", "application/json")48 w.Header().Set("Content-Type", "application/json")
49+ w.Header().Set("Cache-Control", "no-store")
50+ w.Header().Set("Pragma", "no-cache")
47 w.Header().Set("Retry-After", "1")51 w.Header().Set("Retry-After", "1")
48 w.WriteHeader(http.StatusTooManyRequests)52 w.WriteHeader(http.StatusTooManyRequests)
49 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})53 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
M internal/adapters/in/http/admin/middleware_test.go +4 -0
@@ -42,6 +42,8 @@ func TestAuthMiddleware_GlobalRateLimitHit(t *testing.T) {
4242
4343 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
4444 assert.Contains(t, rec.Body.String(), "rate limit exceeded")
45+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
46+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
4547 }
4648
4749 func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
@@ -67,6 +69,8 @@ func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
6769
6870 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
6971 assert.Contains(t, rec.Body.String(), "rate limit exceeded")
72+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
73+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
7074 }
7175
7276 func TestAuthMiddleware_TrustedProxy(t *testing.T) {
@@ -42,6 +42,8 @@ func TestAuthMiddleware_GlobalRateLimitHit(t *testing.T) {
42 42
43 assert.Equal(t, http.StatusTooManyRequests, rec.Code)43 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
44 assert.Contains(t, rec.Body.String(), "rate limit exceeded")44 assert.Contains(t, rec.Body.String(), "rate limit exceeded")
45+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
46+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
45 }47 }
46 48
47 func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {49 func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
@@ -67,6 +69,8 @@ func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
67 69
68 assert.Equal(t, http.StatusTooManyRequests, rec.Code)70 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
69 assert.Contains(t, rec.Body.String(), "rate limit exceeded")71 assert.Contains(t, rec.Body.String(), "rate limit exceeded")
72+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
73+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
70 }74 }
71 75
72 func TestAuthMiddleware_TrustedProxy(t *testing.T) {76 func TestAuthMiddleware_TrustedProxy(t *testing.T) {
M internal/adapters/out/domainsecrets/store.go +7 -11
@@ -285,8 +285,8 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str
285285 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.
286286 //
287287 // SECURITY: Validates domain and ensures the resulting path stays within envDir.
288-func (s *FileStore) getEnvFilePath(domainName string) string {
289- safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
288+func (s *FileStore) getEnvFilePath(domainName string) (string, error) {
289+ storageKey, err := domain.NewEnvStorageKey(domainName)
290290 if err != nil {
291291 s.log.Warn().
292292 Str(zerowrap.FieldLayer, "adapter").
@@ -294,10 +294,10 @@ func (s *FileStore) getEnvFilePath(domainName string) string {
294294 Str("domain", domainName).
295295 Err(err).
296296 Msg("rejected invalid domain")
297- return ""
297+ return "", err
298298 }
299299
300- fullPath := filepath.Join(s.envDir, safeDomain+".env")
300+ fullPath := filepath.Join(s.envDir, storageKey.FileName())
301301
302302 // SECURITY: Final validation - ensure path stays within envDir
303303 cleanPath := filepath.Clean(fullPath)
@@ -309,20 +309,16 @@ func (s *FileStore) getEnvFilePath(domainName string) string {
309309 Str("domain", domainName).
310310 Str("attempted_path", fullPath).
311311 Msg("path traversal attempt blocked - path escapes env directory")
312- return ""
312+ return "", domain.ErrPathTraversal
313313 }
314314
315- return fullPath
315+ return fullPath, nil
316316 }
317317
318318 // validateEnvFilePath validates that a domain produces a valid env file path.
319319 // Returns an error if the domain is invalid or would result in path traversal.
320320 func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
321- path := s.getEnvFilePath(domainName)
322- if path == "" {
323- return "", domain.ErrPathTraversal
324- }
325- return path, nil
321+ return s.getEnvFilePath(domainName)
326322 }
327323
328324 // getAttachmentEnvFilePath converts a container name to its attachment env file path.
@@ -285,8 +285,8 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str
285 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.285 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.
286 //286 //
287 // SECURITY: Validates domain and ensures the resulting path stays within envDir.287 // SECURITY: Validates domain and ensures the resulting path stays within envDir.
288-func (s *FileStore) getEnvFilePath(domainName string) string {288+func (s *FileStore) getEnvFilePath(domainName string) (string, error) {
289- safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)289+ storageKey, err := domain.NewEnvStorageKey(domainName)
290 if err != nil {290 if err != nil {
291 s.log.Warn().291 s.log.Warn().
292 Str(zerowrap.FieldLayer, "adapter").292 Str(zerowrap.FieldLayer, "adapter").
@@ -294,10 +294,10 @@ func (s *FileStore) getEnvFilePath(domainName string) string {
294 Str("domain", domainName).294 Str("domain", domainName).
295 Err(err).295 Err(err).
296 Msg("rejected invalid domain")296 Msg("rejected invalid domain")
297- return ""297+ return "", err
298 }298 }
299 299
300- fullPath := filepath.Join(s.envDir, safeDomain+".env")300+ fullPath := filepath.Join(s.envDir, storageKey.FileName())
301 301
302 // SECURITY: Final validation - ensure path stays within envDir302 // SECURITY: Final validation - ensure path stays within envDir
303 cleanPath := filepath.Clean(fullPath)303 cleanPath := filepath.Clean(fullPath)
@@ -309,20 +309,16 @@ func (s *FileStore) getEnvFilePath(domainName string) string {
309 Str("domain", domainName).309 Str("domain", domainName).
310 Str("attempted_path", fullPath).310 Str("attempted_path", fullPath).
311 Msg("path traversal attempt blocked - path escapes env directory")311 Msg("path traversal attempt blocked - path escapes env directory")
312- return ""312+ return "", domain.ErrPathTraversal
313 }313 }
314 314
315- return fullPath315+ return fullPath, nil
316 }316 }
317 317
318 // validateEnvFilePath validates that a domain produces a valid env file path.318 // validateEnvFilePath validates that a domain produces a valid env file path.
319 // Returns an error if the domain is invalid or would result in path traversal.319 // Returns an error if the domain is invalid or would result in path traversal.
320 func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {320 func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
321- path := s.getEnvFilePath(domainName)321+ return s.getEnvFilePath(domainName)
322- if path == "" {
323- return "", domain.ErrPathTraversal
324- }
325- return path, nil
326 }322 }
327 323
328 // getAttachmentEnvFilePath converts a container name to its attachment env file path.324 // getAttachmentEnvFilePath converts a container name to its attachment env file path.
M internal/adapters/out/domainsecrets/store_test.go +8 -6
@@ -101,7 +101,7 @@ func TestFileStore_PathContainment(t *testing.T) {
101101 require.NoError(t, err)
102102
103103 // Verify file exists inside tmpDir
104- expectedFile := filepath.Join(tmpDir, "example__com.env")
104+ expectedFile := filepath.Join(tmpDir, "ZXhhbXBsZS5jb20.env")
105105 _, err = os.Stat(expectedFile)
106106 assert.NoError(t, err, "expected env file to exist at %s", expectedFile)
107107
@@ -407,21 +407,23 @@ func TestGetEnvFilePath(t *testing.T) {
407407 wantEmpty bool
408408 wantFilename string
409409 }{
410- {"example.com", false, "example__com.env"},
411- {"sub.example.com", false, "sub__example__com.env"},
412- {"example.com:8080", false, "example__com-_8080.env"},
413- {"example.com/path", false, "example__com--path.env"},
410+ {"example.com", false, "ZXhhbXBsZS5jb20.env"},
411+ {"sub.example.com", false, "c3ViLmV4YW1wbGUuY29t.env"},
412+ {"example.com:8080", false, "ZXhhbXBsZS5jb206ODA4MA.env"},
413+ {"example.com/path", false, "ZXhhbXBsZS5jb20vcGF0aA.env"},
414414 {"../evil", true, ""},
415415 {"", true, ""},
416416 }
417417
418418 for _, tt := range tests {
419419 t.Run(tt.domain, func(t *testing.T) {
420- path := store.getEnvFilePath(tt.domain)
420+ path, err := store.getEnvFilePath(tt.domain)
421421
422422 if tt.wantEmpty {
423+ assert.Error(t, err)
423424 assert.Empty(t, path)
424425 } else {
426+ require.NoError(t, err)
425427 assert.NotEmpty(t, path)
426428 assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
427429 }
@@ -101,7 +101,7 @@ func TestFileStore_PathContainment(t *testing.T) {
101 require.NoError(t, err)101 require.NoError(t, err)
102 102
103 // Verify file exists inside tmpDir103 // Verify file exists inside tmpDir
104- expectedFile := filepath.Join(tmpDir, "example__com.env")104+ expectedFile := filepath.Join(tmpDir, "ZXhhbXBsZS5jb20.env")
105 _, err = os.Stat(expectedFile)105 _, err = os.Stat(expectedFile)
106 assert.NoError(t, err, "expected env file to exist at %s", expectedFile)106 assert.NoError(t, err, "expected env file to exist at %s", expectedFile)
107 107
@@ -407,21 +407,23 @@ func TestGetEnvFilePath(t *testing.T) {
407 wantEmpty bool407 wantEmpty bool
408 wantFilename string408 wantFilename string
409 }{409 }{
410- {"example.com", false, "example__com.env"},410+ {"example.com", false, "ZXhhbXBsZS5jb20.env"},
411- {"sub.example.com", false, "sub__example__com.env"},411+ {"sub.example.com", false, "c3ViLmV4YW1wbGUuY29t.env"},
412- {"example.com:8080", false, "example__com-_8080.env"},412+ {"example.com:8080", false, "ZXhhbXBsZS5jb206ODA4MA.env"},
413- {"example.com/path", false, "example__com--path.env"},413+ {"example.com/path", false, "ZXhhbXBsZS5jb20vcGF0aA.env"},
414 {"../evil", true, ""},414 {"../evil", true, ""},
415 {"", true, ""},415 {"", true, ""},
416 }416 }
417 417
418 for _, tt := range tests {418 for _, tt := range tests {
419 t.Run(tt.domain, func(t *testing.T) {419 t.Run(tt.domain, func(t *testing.T) {
420- path := store.getEnvFilePath(tt.domain)420+ path, err := store.getEnvFilePath(tt.domain)
421 421
422 if tt.wantEmpty {422 if tt.wantEmpty {
423+ assert.Error(t, err)
423 assert.Empty(t, path)424 assert.Empty(t, path)
424 } else {425 } else {
426+ require.NoError(t, err)
425 assert.NotEmpty(t, path)427 assert.NotEmpty(t, path)
426 assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)428 assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
427 }429 }
M internal/adapters/out/envloader/file.go +21 -10
@@ -63,7 +63,10 @@ func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
6363 log := zerowrap.FromCtx(ctx)
6464
6565 envVars := []string{}
66- envFile := l.getEnvFilePath(domain)
66+ envFile, err := l.getEnvFilePath(domain)
67+ if err != nil {
68+ return nil, log.WrapErr(err, "invalid env file domain")
69+ }
6770
6871 // Check if env file exists
6972 if _, err := os.Stat(envFile); os.IsNotExist(err) {
@@ -143,7 +146,10 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
143146 })
144147 log := zerowrap.FromCtx(ctx)
145148
146- envFile := l.getEnvFilePath(domain)
149+ envFile, err := l.getEnvFilePath(domain)
150+ if err != nil {
151+ return log.WrapErr(err, "invalid env file domain")
152+ }
147153
148154 // Check if file already exists
149155 if _, err := os.Stat(envFile); err == nil {
@@ -170,10 +176,14 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
170176 }
171177
172178 // EnvFileExists checks if an environment file exists for a domain.
173-func (l *FileLoader) EnvFileExists(domain string) bool {
174- envFile := l.getEnvFilePath(domain)
175- _, err := os.Stat(envFile)
176- return err == nil
179+func (l *FileLoader) EnvFileExists(domain string) (bool, error) {
180+ envFile, err := l.getEnvFilePath(domain)
181+ if err != nil {
182+ return false, err
183+ }
184+
185+ _, err = os.Stat(envFile)
186+ return err == nil, nil
177187 }
178188
179189 // resolveSecrets resolves any secret references in the value.
@@ -223,10 +233,11 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string,
223233 return result, nil
224234 }
225235
226-func (l *FileLoader) getEnvFilePath(domainName string) string {
227- safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
236+func (l *FileLoader) getEnvFilePath(domainName string) (string, error) {
237+ storageKey, err := domain.NewEnvStorageKey(domainName)
228238 if err != nil {
229- return filepath.Join(l.envDir, domainName+".env")
239+ return "", err
230240 }
231- return filepath.Join(l.envDir, safeDomain+".env")
241+
242+ return filepath.Join(l.envDir, storageKey.FileName()), nil
232243 }
@@ -63,7 +63,10 @@ func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
63 log := zerowrap.FromCtx(ctx)63 log := zerowrap.FromCtx(ctx)
64 64
65 envVars := []string{}65 envVars := []string{}
66- envFile := l.getEnvFilePath(domain)66+ envFile, err := l.getEnvFilePath(domain)
67+ if err != nil {
68+ return nil, log.WrapErr(err, "invalid env file domain")
69+ }
67 70
68 // Check if env file exists71 // Check if env file exists
69 if _, err := os.Stat(envFile); os.IsNotExist(err) {72 if _, err := os.Stat(envFile); os.IsNotExist(err) {
@@ -143,7 +146,10 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
143 })146 })
144 log := zerowrap.FromCtx(ctx)147 log := zerowrap.FromCtx(ctx)
145 148
146- envFile := l.getEnvFilePath(domain)149+ envFile, err := l.getEnvFilePath(domain)
150+ if err != nil {
151+ return log.WrapErr(err, "invalid env file domain")
152+ }
147 153
148 // Check if file already exists154 // Check if file already exists
149 if _, err := os.Stat(envFile); err == nil {155 if _, err := os.Stat(envFile); err == nil {
@@ -170,10 +176,14 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
170 }176 }
171 177
172 // EnvFileExists checks if an environment file exists for a domain.178 // EnvFileExists checks if an environment file exists for a domain.
173-func (l *FileLoader) EnvFileExists(domain string) bool {179+func (l *FileLoader) EnvFileExists(domain string) (bool, error) {
174- envFile := l.getEnvFilePath(domain)180+ envFile, err := l.getEnvFilePath(domain)
175- _, err := os.Stat(envFile)181+ if err != nil {
176- return err == nil182+ return false, err
183+ }
184+
185+ _, err = os.Stat(envFile)
186+ return err == nil, nil
177 }187 }
178 188
179 // resolveSecrets resolves any secret references in the value.189 // resolveSecrets resolves any secret references in the value.
@@ -223,10 +233,11 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string,
223 return result, nil233 return result, nil
224 }234 }
225 235
226-func (l *FileLoader) getEnvFilePath(domainName string) string {236+func (l *FileLoader) getEnvFilePath(domainName string) (string, error) {
227- safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)237+ storageKey, err := domain.NewEnvStorageKey(domainName)
228 if err != nil {238 if err != nil {
229- return filepath.Join(l.envDir, domainName+".env")239+ return "", err
230 }240 }
231- return filepath.Join(l.envDir, safeDomain+".env")241+
242+ return filepath.Join(l.envDir, storageKey.FileName()), nil
232 }243 }
M internal/adapters/out/envloader/file_test.go +35 -11
@@ -11,6 +11,7 @@ import (
1111 "github.com/stretchr/testify/require"
1212
1313 "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
1415 )
1516
1617 func TestFileLoader_ResolveSecrets(t *testing.T) {
@@ -121,7 +122,9 @@ func TestFileLoader_LoadEnv(t *testing.T) {
121122 NODE_ENV=production
122123 API_URL=https://api.example.com
123124 `
124- envFile := filepath.Join(tmpDir, "app__example__com.env")
125+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
126+ require.NoError(t, err)
127+ envFile := filepath.Join(tmpDir, storageKey.FileName())
125128 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
126129
127130 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -146,7 +149,9 @@ API_URL=https://api.example.com
146149 envContent := `NODE_ENV=production
147150 API_KEY=${pass:app/api-key}
148151 `
149- envFile := filepath.Join(tmpDir, "app__example__com.env")
152+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
153+ require.NoError(t, err)
154+ envFile := filepath.Join(tmpDir, storageKey.FileName())
150155 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
151156
152157 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -164,7 +169,9 @@ API_KEY=${pass:app/api-key}
164169 envContent := `DOUBLE_QUOTED="hello world"
165170 SINGLE_QUOTED='hello world'
166171 `
167- envFile := filepath.Join(tmpDir, "app__example__com.env")
172+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
173+ require.NoError(t, err)
174+ envFile := filepath.Join(tmpDir, storageKey.FileName())
168175 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
169176
170177 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -183,7 +190,9 @@ SINGLE_QUOTED='hello world'
183190 invalid line without equals
184191 ALSO_VALID=another
185192 `
186- envFile := filepath.Join(tmpDir, "app__example__com.env")
193+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
194+ require.NoError(t, err)
195+ envFile := filepath.Join(tmpDir, storageKey.FileName())
187196 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
188197
189198 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -205,14 +214,15 @@ func TestFileLoader_GetEnvFilePath(t *testing.T) {
205214 domain string
206215 expected string
207216 }{
208- {"app.example.com", "app__example__com.env"},
209- {"api.sub.example.com", "api__sub__example__com.env"},
210- {"localhost:8080", "localhost-_8080.env"},
217+ {"app.example.com", "YXBwLmV4YW1wbGUuY29t.env"},
218+ {"api.sub.example.com", "YXBpLnN1Yi5leGFtcGxlLmNvbQ.env"},
219+ {"localhost:8080", "bG9jYWxob3N0OjgwODA.env"},
211220 }
212221
213222 for _, tt := range tests {
214223 t.Run(tt.domain, func(t *testing.T) {
215- result := loader.getEnvFilePath(tt.domain)
224+ result, err := loader.getEnvFilePath(tt.domain)
225+ require.NoError(t, err)
216226 assert.Equal(t, filepath.Join(tmpDir, tt.expected), result)
217227 })
218228 }
@@ -225,11 +235,25 @@ func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) {
225235 loader, err := NewFileLoader(tmpDir, log)
226236 require.NoError(t, err)
227237
228- dotPath := loader.getEnvFilePath("app.example.com")
229- portPath := loader.getEnvFilePath("app:example:com")
230- slashPath := loader.getEnvFilePath("app/example/com")
238+ dotPath, err := loader.getEnvFilePath("app.example.com")
239+ require.NoError(t, err)
240+ portPath, err := loader.getEnvFilePath("app:example:com")
241+ require.NoError(t, err)
242+ slashPath, err := loader.getEnvFilePath("app/example/com")
243+ require.NoError(t, err)
231244
232245 assert.NotEqual(t, dotPath, portPath)
233246 assert.NotEqual(t, dotPath, slashPath)
234247 assert.NotEqual(t, portPath, slashPath)
235248 }
249+
250+func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) {
251+ tmpDir := t.TempDir()
252+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
253+
254+ loader, err := NewFileLoader(tmpDir, log)
255+ require.NoError(t, err)
256+
257+ _, err = loader.getEnvFilePath("../etc/passwd")
258+ require.ErrorIs(t, err, domain.ErrPathTraversal)
259+}
@@ -11,6 +11,7 @@ import (
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13 "github.com/bnema/gordon/internal/boundaries/out/mocks"13 "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
14 )15 )
15 16
16 func TestFileLoader_ResolveSecrets(t *testing.T) {17 func TestFileLoader_ResolveSecrets(t *testing.T) {
@@ -121,7 +122,9 @@ func TestFileLoader_LoadEnv(t *testing.T) {
121 NODE_ENV=production122 NODE_ENV=production
122 API_URL=https://api.example.com123 API_URL=https://api.example.com
123 `124 `
124- envFile := filepath.Join(tmpDir, "app__example__com.env")125+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
126+ require.NoError(t, err)
127+ envFile := filepath.Join(tmpDir, storageKey.FileName())
125 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))128 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
126 129
127 envVars, err := loader.LoadEnv(ctx, "app.example.com")130 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -146,7 +149,9 @@ API_URL=https://api.example.com
146 envContent := `NODE_ENV=production149 envContent := `NODE_ENV=production
147 API_KEY=${pass:app/api-key}150 API_KEY=${pass:app/api-key}
148 `151 `
149- envFile := filepath.Join(tmpDir, "app__example__com.env")152+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
153+ require.NoError(t, err)
154+ envFile := filepath.Join(tmpDir, storageKey.FileName())
150 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))155 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
151 156
152 envVars, err := loader.LoadEnv(ctx, "app.example.com")157 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -164,7 +169,9 @@ API_KEY=${pass:app/api-key}
164 envContent := `DOUBLE_QUOTED="hello world"169 envContent := `DOUBLE_QUOTED="hello world"
165 SINGLE_QUOTED='hello world'170 SINGLE_QUOTED='hello world'
166 `171 `
167- envFile := filepath.Join(tmpDir, "app__example__com.env")172+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
173+ require.NoError(t, err)
174+ envFile := filepath.Join(tmpDir, storageKey.FileName())
168 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))175 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
169 176
170 envVars, err := loader.LoadEnv(ctx, "app.example.com")177 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -183,7 +190,9 @@ SINGLE_QUOTED='hello world'
183 invalid line without equals190 invalid line without equals
184 ALSO_VALID=another191 ALSO_VALID=another
185 `192 `
186- envFile := filepath.Join(tmpDir, "app__example__com.env")193+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
194+ require.NoError(t, err)
195+ envFile := filepath.Join(tmpDir, storageKey.FileName())
187 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))196 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
188 197
189 envVars, err := loader.LoadEnv(ctx, "app.example.com")198 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -205,14 +214,15 @@ func TestFileLoader_GetEnvFilePath(t *testing.T) {
205 domain string214 domain string
206 expected string215 expected string
207 }{216 }{
208- {"app.example.com", "app__example__com.env"},217+ {"app.example.com", "YXBwLmV4YW1wbGUuY29t.env"},
209- {"api.sub.example.com", "api__sub__example__com.env"},218+ {"api.sub.example.com", "YXBpLnN1Yi5leGFtcGxlLmNvbQ.env"},
210- {"localhost:8080", "localhost-_8080.env"},219+ {"localhost:8080", "bG9jYWxob3N0OjgwODA.env"},
211 }220 }
212 221
213 for _, tt := range tests {222 for _, tt := range tests {
214 t.Run(tt.domain, func(t *testing.T) {223 t.Run(tt.domain, func(t *testing.T) {
215- result := loader.getEnvFilePath(tt.domain)224+ result, err := loader.getEnvFilePath(tt.domain)
225+ require.NoError(t, err)
216 assert.Equal(t, filepath.Join(tmpDir, tt.expected), result)226 assert.Equal(t, filepath.Join(tmpDir, tt.expected), result)
217 })227 })
218 }228 }
@@ -225,11 +235,25 @@ func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) {
225 loader, err := NewFileLoader(tmpDir, log)235 loader, err := NewFileLoader(tmpDir, log)
226 require.NoError(t, err)236 require.NoError(t, err)
227 237
228- dotPath := loader.getEnvFilePath("app.example.com")238+ dotPath, err := loader.getEnvFilePath("app.example.com")
229- portPath := loader.getEnvFilePath("app:example:com")239+ require.NoError(t, err)
230- slashPath := loader.getEnvFilePath("app/example/com")240+ portPath, err := loader.getEnvFilePath("app:example:com")
241+ require.NoError(t, err)
242+ slashPath, err := loader.getEnvFilePath("app/example/com")
243+ require.NoError(t, err)
231 244
232 assert.NotEqual(t, dotPath, portPath)245 assert.NotEqual(t, dotPath, portPath)
233 assert.NotEqual(t, dotPath, slashPath)246 assert.NotEqual(t, dotPath, slashPath)
234 assert.NotEqual(t, portPath, slashPath)247 assert.NotEqual(t, portPath, slashPath)
235 }248 }
249+
250+func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) {
251+ tmpDir := t.TempDir()
252+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
253+
254+ loader, err := NewFileLoader(tmpDir, log)
255+ require.NoError(t, err)
256+
257+ _, err = loader.getEnvFilePath("../etc/passwd")
258+ require.ErrorIs(t, err, domain.ErrPathTraversal)
259+}
M internal/adapters/out/envloader/pass_loader.go +3 -3
@@ -104,11 +104,11 @@ func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error {
104104 }
105105
106106 // EnvFileExists checks if a manifest exists for a domain in pass.
107-func (l *PassLoader) EnvFileExists(domain string) bool {
107+func (l *PassLoader) EnvFileExists(domain string) (bool, error) {
108108 exists, err := l.store.ManifestExists(domain)
109109 if err != nil {
110110 l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest")
111- return false
111+ return false, err
112112 }
113- return exists
113+ return exists, nil
114114 }
@@ -104,11 +104,11 @@ func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error {
104 }104 }
105 105
106 // EnvFileExists checks if a manifest exists for a domain in pass.106 // EnvFileExists checks if a manifest exists for a domain in pass.
107-func (l *PassLoader) EnvFileExists(domain string) bool {107+func (l *PassLoader) EnvFileExists(domain string) (bool, error) {
108 exists, err := l.store.ManifestExists(domain)108 exists, err := l.store.ManifestExists(domain)
109 if err != nil {109 if err != nil {
110 l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest")110 l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest")
111- return false111+ return false, err
112 }112 }
113- return exists113+ return exists, nil
114 }114 }
M internal/app/kernel_test.go +1 -0
@@ -22,6 +22,7 @@ data_dir = %q
2222
2323 [auth]
2424 enabled = false
25+secrets_backend = "unsafe"
2526 `, dataDir)
2627
2728 err := os.WriteFile(cfgPath, []byte(cfg), 0o600)
@@ -22,6 +22,7 @@ data_dir = %q
22 22
23 [auth]23 [auth]
24 enabled = false24 enabled = false
25+secrets_backend = "unsafe"
25 `, dataDir)26 `, dataDir)
26 27
27 err := os.WriteFile(cfgPath, []byte(cfg), 0o600)28 err := os.WriteFile(cfgPath, []byte(cfg), 0o600)
M internal/app/run.go +1 -1
@@ -2347,7 +2347,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
23472347 v.SetDefault("auth.enabled", true)
23482348 // Note: auth.type is intentionally not set - it's inferred from config
23492349 // If password_hash is set -> password mode, otherwise -> token mode
2350- v.SetDefault("auth.secrets_backend", "unsafe")
2350+ v.SetDefault("auth.secrets_backend", "")
23512351 v.SetDefault("auth.token_expiry", "720h")
23522352 v.SetDefault("api.rate_limit.enabled", true)
23532353 v.SetDefault("api.rate_limit.global_rps", 500)
@@ -2347,7 +2347,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
2347 v.SetDefault("auth.enabled", true)2347 v.SetDefault("auth.enabled", true)
2348 // Note: auth.type is intentionally not set - it's inferred from config2348 // Note: auth.type is intentionally not set - it's inferred from config
2349 // If password_hash is set -> password mode, otherwise -> token mode2349 // If password_hash is set -> password mode, otherwise -> token mode
2350- v.SetDefault("auth.secrets_backend", "unsafe")2350+ v.SetDefault("auth.secrets_backend", "")
2351 v.SetDefault("auth.token_expiry", "720h")2351 v.SetDefault("auth.token_expiry", "720h")
2352 v.SetDefault("api.rate_limit.enabled", true)2352 v.SetDefault("api.rate_limit.enabled", true)
2353 v.SetDefault("api.rate_limit.global_rps", 500)2353 v.SetDefault("api.rate_limit.global_rps", 500)
M internal/app/run_auth_config_test.go +14 -0
@@ -7,6 +7,7 @@ import (
77
88 "github.com/bnema/gordon/internal/domain"
99 "github.com/bnema/zerowrap"
10+ "github.com/spf13/viper"
1011 )
1112
1213 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
@@ -44,3 +45,16 @@ func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) {
4445 t.Fatalf("unexpected error: %v", err)
4546 }
4647 }
48+
49+func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) {
50+ v := viper.New()
51+
52+ err := loadConfig(v, "")
53+ if err != nil {
54+ t.Fatalf("expected no error, got %v", err)
55+ }
56+
57+ if got := v.GetString("auth.secrets_backend"); got != "" {
58+ t.Fatalf("expected empty default for auth.secrets_backend, got %q", got)
59+ }
60+}
@@ -7,6 +7,7 @@ import (
7 7
8 "github.com/bnema/gordon/internal/domain"8 "github.com/bnema/gordon/internal/domain"
9 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
10+ "github.com/spf13/viper"
10 )11 )
11 12
12 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {13 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
@@ -44,3 +45,16 @@ func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) {
44 t.Fatalf("unexpected error: %v", err)45 t.Fatalf("unexpected error: %v", err)
45 }46 }
46 }47 }
48+
49+func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) {
50+ v := viper.New()
51+
52+ err := loadConfig(v, "")
53+ if err != nil {
54+ t.Fatalf("expected no error, got %v", err)
55+ }
56+
57+ if got := v.GetString("auth.secrets_backend"); got != "" {
58+ t.Fatalf("expected empty default for auth.secrets_backend, got %q", got)
59+ }
60+}
M internal/boundaries/in/mocks/mock_registry_service.go +0 -80
@@ -231,46 +231,6 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte
231231 return _c
232232 }
233233
234-// ClearDeployEventSuppression provides a mock function for the type MockRegistryService
235-func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {
236- _mock.Called(imageName)
237- return
238-}
239-
240-// MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression'
241-type MockRegistryService_ClearDeployEventSuppression_Call struct {
242- *mock.Call
243-}
244-
245-// ClearDeployEventSuppression is a helper method to define mock.On call
246-// - imageName string
247-func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call {
248- return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)}
249-}
250-
251-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
252- _c.Call.Run(func(args mock.Arguments) {
253- var arg0 string
254- if args[0] != nil {
255- arg0 = args[0].(string)
256- }
257- run(
258- arg0,
259- )
260- })
261- return _c
262-}
263-
264-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call {
265- _c.Call.Return()
266- return _c
267-}
268-
269-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
270- _c.Run(run)
271- return _c
272-}
273-
274234 // DeleteManifest provides a mock function for the type MockRegistryService
275235 func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error {
276236 ret := _mock.Called(ctx, name, reference)
@@ -935,43 +895,3 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex
935895 _c.Call.Return(run)
936896 return _c
937897 }
938-
939-// SuppressDeployEvent provides a mock function for the type MockRegistryService
940-func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {
941- _mock.Called(imageName)
942- return
943-}
944-
945-// MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent'
946-type MockRegistryService_SuppressDeployEvent_Call struct {
947- *mock.Call
948-}
949-
950-// SuppressDeployEvent is a helper method to define mock.On call
951-// - imageName string
952-func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call {
953- return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)}
954-}
955-
956-func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
957- _c.Call.Run(func(args mock.Arguments) {
958- var arg0 string
959- if args[0] != nil {
960- arg0 = args[0].(string)
961- }
962- run(
963- arg0,
964- )
965- })
966- return _c
967-}
968-
969-func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call {
970- _c.Call.Return()
971- return _c
972-}
973-
974-func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
975- _c.Run(run)
976- return _c
977-}
@@ -231,46 +231,6 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte
231 return _c231 return _c
232 }232 }
233 233
234-// ClearDeployEventSuppression provides a mock function for the type MockRegistryService
235-func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {
236- _mock.Called(imageName)
237- return
238-}
239-
240-// MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression'
241-type MockRegistryService_ClearDeployEventSuppression_Call struct {
242- *mock.Call
243-}
244-
245-// ClearDeployEventSuppression is a helper method to define mock.On call
246-// - imageName string
247-func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call {
248- return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)}
249-}
250-
251-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
252- _c.Call.Run(func(args mock.Arguments) {
253- var arg0 string
254- if args[0] != nil {
255- arg0 = args[0].(string)
256- }
257- run(
258- arg0,
259- )
260- })
261- return _c
262-}
263-
264-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call {
265- _c.Call.Return()
266- return _c
267-}
268-
269-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
270- _c.Run(run)
271- return _c
272-}
273-
274 // DeleteManifest provides a mock function for the type MockRegistryService234 // DeleteManifest provides a mock function for the type MockRegistryService
275 func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error {235 func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error {
276 ret := _mock.Called(ctx, name, reference)236 ret := _mock.Called(ctx, name, reference)
@@ -935,43 +895,3 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex
935 _c.Call.Return(run)895 _c.Call.Return(run)
936 return _c896 return _c
937 }897 }
938-
939-// SuppressDeployEvent provides a mock function for the type MockRegistryService
940-func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {
941- _mock.Called(imageName)
942- return
943-}
944-
945-// MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent'
946-type MockRegistryService_SuppressDeployEvent_Call struct {
947- *mock.Call
948-}
949-
950-// SuppressDeployEvent is a helper method to define mock.On call
951-// - imageName string
952-func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call {
953- return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)}
954-}
955-
956-func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
957- _c.Call.Run(func(args mock.Arguments) {
958- var arg0 string
959- if args[0] != nil {
960- arg0 = args[0].(string)
961- }
962- run(
963- arg0,
964- )
965- })
966- return _c
967-}
968-
969-func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call {
970- _c.Call.Return()
971- return _c
972-}
973-
974-func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
975- _c.Run(run)
976- return _c
977-}
A internal/boundaries/in/mocks/mock_registry_service_deploy.go +9 -0
@@ -0,0 +1,9 @@
1+package mocks
2+
3+// SuppressDeployEvent satisfies the admin handler's composite registry/deploy test dependency.
4+// Admin handler tests that only need registry behavior do not assert on deploy suppression.
5+func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {}
6+
7+// ClearDeployEventSuppression satisfies the admin handler's composite registry/deploy test dependency.
8+// Admin handler tests that only need registry behavior do not assert on deploy suppression.
9+func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {}
@@ -0,0 +1,9 @@
1+package mocks
2+
3+// SuppressDeployEvent satisfies the admin handler's composite registry/deploy test dependency.
4+// Admin handler tests that only need registry behavior do not assert on deploy suppression.
5+func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {}
6+
7+// ClearDeployEventSuppression satisfies the admin handler's composite registry/deploy test dependency.
8+// Admin handler tests that only need registry behavior do not assert on deploy suppression.
9+func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {}
M internal/boundaries/out/envloader.go +1 -1
@@ -12,5 +12,5 @@ type EnvLoader interface {
1212 CreateEnvFile(ctx context.Context, domain string) error
1313
1414 // EnvFileExists checks if an environment file exists for a domain.
15- EnvFileExists(domain string) bool
15+ EnvFileExists(domain string) (bool, error)
1616 }
@@ -12,5 +12,5 @@ type EnvLoader interface {
12 CreateEnvFile(ctx context.Context, domain string) error12 CreateEnvFile(ctx context.Context, domain string) error
13 13
14 // EnvFileExists checks if an environment file exists for a domain.14 // EnvFileExists checks if an environment file exists for a domain.
15- EnvFileExists(domain string) bool15+ EnvFileExists(domain string) (bool, error)
16 }16 }
M internal/boundaries/out/mocks/mock_env_loader.go +14 -5
@@ -95,7 +95,7 @@ func (_c *MockEnvLoader_CreateEnvFile_Call) RunAndReturn(run func(ctx context.Co
9595 }
9696
9797 // EnvFileExists provides a mock function for the type MockEnvLoader
98-func (_mock *MockEnvLoader) EnvFileExists(domain string) bool {
98+func (_mock *MockEnvLoader) EnvFileExists(domain string) (bool, error) {
9999 ret := _mock.Called(domain)
100100
101101 if len(ret) == 0 {
@@ -103,12 +103,21 @@ func (_mock *MockEnvLoader) EnvFileExists(domain string) bool {
103103 }
104104
105105 var r0 bool
106+ var r1 error
107+ if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok {
108+ return returnFunc(domain)
109+ }
106110 if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
107111 r0 = returnFunc(domain)
108112 } else {
109113 r0 = ret.Get(0).(bool)
110114 }
111- return r0
115+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
116+ r1 = returnFunc(domain)
117+ } else {
118+ r1 = ret.Error(1)
119+ }
120+ return r0, r1
112121 }
113122
114123 // MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists'
@@ -135,12 +144,12 @@ func (_c *MockEnvLoader_EnvFileExists_Call) Run(run func(domain string)) *MockEn
135144 return _c
136145 }
137146
138-func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool) *MockEnvLoader_EnvFileExists_Call {
139- _c.Call.Return(b)
147+func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool, err error) *MockEnvLoader_EnvFileExists_Call {
148+ _c.Call.Return(b, err)
140149 return _c
141150 }
142151
143-func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) bool) *MockEnvLoader_EnvFileExists_Call {
152+func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) (bool, error)) *MockEnvLoader_EnvFileExists_Call {
144153 _c.Call.Return(run)
145154 return _c
146155 }
@@ -95,7 +95,7 @@ func (_c *MockEnvLoader_CreateEnvFile_Call) RunAndReturn(run func(ctx context.Co
95 }95 }
96 96
97 // EnvFileExists provides a mock function for the type MockEnvLoader97 // EnvFileExists provides a mock function for the type MockEnvLoader
98-func (_mock *MockEnvLoader) EnvFileExists(domain string) bool {98+func (_mock *MockEnvLoader) EnvFileExists(domain string) (bool, error) {
99 ret := _mock.Called(domain)99 ret := _mock.Called(domain)
100 100
101 if len(ret) == 0 {101 if len(ret) == 0 {
@@ -103,12 +103,21 @@ func (_mock *MockEnvLoader) EnvFileExists(domain string) bool {
103 }103 }
104 104
105 var r0 bool105 var r0 bool
106+ var r1 error
107+ if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok {
108+ return returnFunc(domain)
109+ }
106 if returnFunc, ok := ret.Get(0).(func(string) bool); ok {110 if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
107 r0 = returnFunc(domain)111 r0 = returnFunc(domain)
108 } else {112 } else {
109 r0 = ret.Get(0).(bool)113 r0 = ret.Get(0).(bool)
110 }114 }
111- return r0115+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
116+ r1 = returnFunc(domain)
117+ } else {
118+ r1 = ret.Error(1)
119+ }
120+ return r0, r1
112 }121 }
113 122
114 // MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists'123 // MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists'
@@ -135,12 +144,12 @@ func (_c *MockEnvLoader_EnvFileExists_Call) Run(run func(domain string)) *MockEn
135 return _c144 return _c
136 }145 }
137 146
138-func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool) *MockEnvLoader_EnvFileExists_Call {147+func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool, err error) *MockEnvLoader_EnvFileExists_Call {
139- _c.Call.Return(b)148+ _c.Call.Return(b, err)
140 return _c149 return _c
141 }150 }
142 151
143-func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) bool) *MockEnvLoader_EnvFileExists_Call {152+func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) (bool, error)) *MockEnvLoader_EnvFileExists_Call {
144 _c.Call.Return(run)153 _c.Call.Return(run)
145 return _c154 return _c
146 }155 }
M internal/domain/env.go +5 -21
@@ -7,34 +7,18 @@ import (
77 "strings"
88 )
99
10-// envDomainRegex validates domain names used for env secrets.
11-// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
12-// Underscores are intentionally disallowed because they would collide with the
13-// on-disk filename encoding used for dots/ports/path separators.
14-var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
1510 var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
1611 var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
1712
1813 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
19-// Returns a safe domain string suitable for filenames.
20-// Uses distinct replacements to avoid collisions between ".", ":", and "/".
14+// Returns the collision-resistant storage key used for filenames.
2115 func SanitizeDomainForEnvFile(domainName string) (string, error) {
22- if domainName == "" {
23- return "", ErrPathTraversal
16+ safeDomain, err := NewEnvStorageKey(domainName)
17+ if err != nil {
18+ return "", err
2419 }
2520
26- if strings.Contains(domainName, "..") {
27- return "", ErrPathTraversal
28- }
29-
30- if !envDomainRegex.MatchString(domainName) {
31- return "", ErrPathTraversal
32- }
33-
34- safeDomain := strings.ReplaceAll(domainName, ".", "__")
35- safeDomain = strings.ReplaceAll(safeDomain, ":", "-_")
36- safeDomain = strings.ReplaceAll(safeDomain, "/", "--")
37- return safeDomain, nil
21+ return string(safeDomain), nil
3822 }
3923
4024 // ParseEnvData parses env data into a key-value map.
@@ -7,34 +7,18 @@ import (
7 "strings"7 "strings"
8 )8 )
9 9
10-// envDomainRegex validates domain names used for env secrets.
11-// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
12-// Underscores are intentionally disallowed because they would collide with the
13-// on-disk filename encoding used for dots/ports/path separators.
14-var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
15 var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)10 var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
16 var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)11 var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
17 12
18 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.13 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
19-// Returns a safe domain string suitable for filenames.14+// Returns the collision-resistant storage key used for filenames.
20-// Uses distinct replacements to avoid collisions between ".", ":", and "/".
21 func SanitizeDomainForEnvFile(domainName string) (string, error) {15 func SanitizeDomainForEnvFile(domainName string) (string, error) {
22- if domainName == "" {16+ safeDomain, err := NewEnvStorageKey(domainName)
23- return "", ErrPathTraversal17+ if err != nil {
18+ return "", err
24 }19 }
25 20
26- if strings.Contains(domainName, "..") {21+ return string(safeDomain), nil
27- return "", ErrPathTraversal
28- }
29-
30- if !envDomainRegex.MatchString(domainName) {
31- return "", ErrPathTraversal
32- }
33-
34- safeDomain := strings.ReplaceAll(domainName, ".", "__")
35- safeDomain = strings.ReplaceAll(safeDomain, ":", "-_")
36- safeDomain = strings.ReplaceAll(safeDomain, "/", "--")
37- return safeDomain, nil
38 }22 }
39 23
40 // ParseEnvData parses env data into a key-value map.24 // ParseEnvData parses env data into a key-value map.
M internal/domain/env_test.go +8 -7
@@ -1,6 +1,7 @@
11 package domain
22
33 import (
4+ "encoding/base64"
45 "strings"
56 "testing"
67
@@ -15,12 +16,12 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
1516 want string
1617 wantErr bool
1718 }{
18- {name: "simple domain", domain: "example.com", want: "example__com"},
19- {name: "subdomain", domain: "app.example.com", want: "app__example__com"},
20- {name: "domain with port", domain: "example.com:8080", want: "example__com-_8080"},
21- {name: "domain with path", domain: "example.com/path", want: "example__com--path"},
22- {name: "single char", domain: "a", want: "a"},
23- {name: "hyphenated", domain: "my-app.example.com", want: "my-app__example__com"},
19+ {name: "simple domain", domain: "example.com", want: base64.RawURLEncoding.EncodeToString([]byte("example.com"))},
20+ {name: "subdomain", domain: "app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("app.example.com"))},
21+ {name: "domain with port", domain: "example.com:8080", want: base64.RawURLEncoding.EncodeToString([]byte("example.com:8080"))},
22+ {name: "domain with path", domain: "example.com/path", want: base64.RawURLEncoding.EncodeToString([]byte("example.com/path"))},
23+ {name: "single char", domain: "a", want: base64.RawURLEncoding.EncodeToString([]byte("a"))},
24+ {name: "hyphenated", domain: "my-app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("my-app.example.com"))},
2425 {name: "empty", domain: "", wantErr: true},
2526 {name: "path traversal", domain: "../etc/passwd", wantErr: true},
2627 {name: "double dots", domain: "foo..bar", wantErr: true},
@@ -28,7 +29,7 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
2829 {name: "ends with dot", domain: "trailing.", wantErr: true},
2930 {name: "space", domain: "has space", wantErr: true},
3031 {name: "special chars", domain: "bad$domain", wantErr: true},
31- {name: "underscore rejected to avoid filename collision", domain: "example_com", wantErr: true},
32+ {name: "underscore rejected as invalid domain character", domain: "example_com", wantErr: true},
3233 }
3334
3435 for _, tt := range tests {
@@ -1,6 +1,7 @@
1 package domain1 package domain
2 2
3 import (3 import (
4+ "encoding/base64"
4 "strings"5 "strings"
5 "testing"6 "testing"
6 7
@@ -15,12 +16,12 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
15 want string16 want string
16 wantErr bool17 wantErr bool
17 }{18 }{
18- {name: "simple domain", domain: "example.com", want: "example__com"},19+ {name: "simple domain", domain: "example.com", want: base64.RawURLEncoding.EncodeToString([]byte("example.com"))},
19- {name: "subdomain", domain: "app.example.com", want: "app__example__com"},20+ {name: "subdomain", domain: "app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("app.example.com"))},
20- {name: "domain with port", domain: "example.com:8080", want: "example__com-_8080"},21+ {name: "domain with port", domain: "example.com:8080", want: base64.RawURLEncoding.EncodeToString([]byte("example.com:8080"))},
21- {name: "domain with path", domain: "example.com/path", want: "example__com--path"},22+ {name: "domain with path", domain: "example.com/path", want: base64.RawURLEncoding.EncodeToString([]byte("example.com/path"))},
22- {name: "single char", domain: "a", want: "a"},23+ {name: "single char", domain: "a", want: base64.RawURLEncoding.EncodeToString([]byte("a"))},
23- {name: "hyphenated", domain: "my-app.example.com", want: "my-app__example__com"},24+ {name: "hyphenated", domain: "my-app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("my-app.example.com"))},
24 {name: "empty", domain: "", wantErr: true},25 {name: "empty", domain: "", wantErr: true},
25 {name: "path traversal", domain: "../etc/passwd", wantErr: true},26 {name: "path traversal", domain: "../etc/passwd", wantErr: true},
26 {name: "double dots", domain: "foo..bar", wantErr: true},27 {name: "double dots", domain: "foo..bar", wantErr: true},
@@ -28,7 +29,7 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
28 {name: "ends with dot", domain: "trailing.", wantErr: true},29 {name: "ends with dot", domain: "trailing.", wantErr: true},
29 {name: "space", domain: "has space", wantErr: true},30 {name: "space", domain: "has space", wantErr: true},
30 {name: "special chars", domain: "bad$domain", wantErr: true},31 {name: "special chars", domain: "bad$domain", wantErr: true},
31- {name: "underscore rejected to avoid filename collision", domain: "example_com", wantErr: true},32+ {name: "underscore rejected as invalid domain character", domain: "example_com", wantErr: true},
32 }33 }
33 34
34 for _, tt := range tests {35 for _, tt := range tests {
A internal/domain/envfile.go +47 -0
@@ -0,0 +1,47 @@
1+package domain
2+
3+import (
4+ "encoding/base64"
5+ "regexp"
6+ "strings"
7+)
8+
9+// envDomainRegex validates domain names used for env-backed secret storage.
10+// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
11+var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
12+
13+// EnvStorageKey is the domain-level storage identifier used for env-backed secrets.
14+// It is deterministic, collision-resistant, and safe to use in file names.
15+type EnvStorageKey string
16+
17+// NewEnvStorageKey validates a route domain and returns the storage-safe identifier
18+// used by env/secrets adapters. The identifier is reversible and unique for the input.
19+func NewEnvStorageKey(domainName string) (EnvStorageKey, error) {
20+ if err := ValidateEnvStorageDomain(domainName); err != nil {
21+ return "", err
22+ }
23+
24+ return EnvStorageKey(base64.RawURLEncoding.EncodeToString([]byte(domainName))), nil
25+}
26+
27+// ValidateEnvStorageDomain validates domains used for env-backed secret storage.
28+func ValidateEnvStorageDomain(domainName string) error {
29+ if domainName == "" {
30+ return ErrPathTraversal
31+ }
32+
33+ if strings.Contains(domainName, "..") {
34+ return ErrPathTraversal
35+ }
36+
37+ if !envDomainRegex.MatchString(domainName) {
38+ return ErrPathTraversal
39+ }
40+
41+ return nil
42+}
43+
44+// FileName returns the on-disk env filename for this storage key.
45+func (k EnvStorageKey) FileName() string {
46+ return string(k) + ".env"
47+}
@@ -0,0 +1,47 @@
1+package domain
2+
3+import (
4+ "encoding/base64"
5+ "regexp"
6+ "strings"
7+)
8+
9+// envDomainRegex validates domain names used for env-backed secret storage.
10+// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
11+var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
12+
13+// EnvStorageKey is the domain-level storage identifier used for env-backed secrets.
14+// It is deterministic, collision-resistant, and safe to use in file names.
15+type EnvStorageKey string
16+
17+// NewEnvStorageKey validates a route domain and returns the storage-safe identifier
18+// used by env/secrets adapters. The identifier is reversible and unique for the input.
19+func NewEnvStorageKey(domainName string) (EnvStorageKey, error) {
20+ if err := ValidateEnvStorageDomain(domainName); err != nil {
21+ return "", err
22+ }
23+
24+ return EnvStorageKey(base64.RawURLEncoding.EncodeToString([]byte(domainName))), nil
25+}
26+
27+// ValidateEnvStorageDomain validates domains used for env-backed secret storage.
28+func ValidateEnvStorageDomain(domainName string) error {
29+ if domainName == "" {
30+ return ErrPathTraversal
31+ }
32+
33+ if strings.Contains(domainName, "..") {
34+ return ErrPathTraversal
35+ }
36+
37+ if !envDomainRegex.MatchString(domainName) {
38+ return ErrPathTraversal
39+ }
40+
41+ return nil
42+}
43+
44+// FileName returns the on-disk env filename for this storage key.
45+func (k EnvStorageKey) FileName() string {
46+ return string(k) + ".env"
47+}
M internal/usecase/container/autoroute.go +10 -7
@@ -273,7 +273,10 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
273273 }
274274
275275 // Load existing env file for this domain
276- envFileName := domainToEnvFileName(routeDomain)
276+ envFileName, err := domainToEnvFileName(routeDomain)
277+ if err != nil {
278+ return fmt.Errorf("invalid env storage domain: %w", err)
279+ }
277280 envFileDst := filepath.Join(h.envDir, envFileName)
278281
279282 existingEnv := make(map[string]string)
@@ -465,14 +468,14 @@ func parseEnvFile(data []byte) (map[string]string, error) {
465468 }
466469
467470 // domainToEnvFileName converts a domain to an env file name.
468-// e.g., "app.mydomain.com" -> "app_mydomain_com.env"
469-// Must match the naming convention in envloader.FileLoader.getEnvFilePath
470-func domainToEnvFileName(domainName string) string {
471- name, err := domain.SanitizeDomainForEnvFile(domainName)
471+// Must match the naming convention in envloader.FileLoader.getEnvFilePath.
472+func domainToEnvFileName(domainName string) (string, error) {
473+ storageKey, err := domain.NewEnvStorageKey(domainName)
472474 if err != nil {
473- return domainName + ".env"
475+ return "", err
474476 }
475- return name + ".env"
477+
478+ return storageKey.FileName(), nil
476479 }
477480
478481 // writeEnvFile writes environment variables to a file.
@@ -273,7 +273,10 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
273 }273 }
274 274
275 // Load existing env file for this domain275 // Load existing env file for this domain
276- envFileName := domainToEnvFileName(routeDomain)276+ envFileName, err := domainToEnvFileName(routeDomain)
277+ if err != nil {
278+ return fmt.Errorf("invalid env storage domain: %w", err)
279+ }
277 envFileDst := filepath.Join(h.envDir, envFileName)280 envFileDst := filepath.Join(h.envDir, envFileName)
278 281
279 existingEnv := make(map[string]string)282 existingEnv := make(map[string]string)
@@ -465,14 +468,14 @@ func parseEnvFile(data []byte) (map[string]string, error) {
465 }468 }
466 469
467 // domainToEnvFileName converts a domain to an env file name.470 // domainToEnvFileName converts a domain to an env file name.
468-// e.g., "app.mydomain.com" -> "app_mydomain_com.env"471+// Must match the naming convention in envloader.FileLoader.getEnvFilePath.
469-// Must match the naming convention in envloader.FileLoader.getEnvFilePath472+func domainToEnvFileName(domainName string) (string, error) {
470-func domainToEnvFileName(domainName string) string {473+ storageKey, err := domain.NewEnvStorageKey(domainName)
471- name, err := domain.SanitizeDomainForEnvFile(domainName)
472 if err != nil {474 if err != nil {
473- return domainName + ".env"475+ return "", err
474 }476 }
475- return name + ".env"477+
478+ return storageKey.FileName(), nil
476 }479 }
477 480
478 // writeEnvFile writes environment variables to a file.481 // writeEnvFile writes environment variables to a file.
M internal/usecase/container/autoroute_test.go +7 -6
@@ -28,33 +28,34 @@ func TestDomainToEnvFileName(t *testing.T) {
2828 {
2929 name: "simple domain",
3030 domain: "app.example.com",
31- expected: "app__example__com.env",
31+ expected: "YXBwLmV4YW1wbGUuY29t.env",
3232 },
3333 {
3434 name: "domain with port",
3535 domain: "app.example.com:8080",
36- expected: "app__example__com-_8080.env",
36+ expected: "YXBwLmV4YW1wbGUuY29tOjgwODA.env",
3737 },
3838 {
3939 name: "domain with path",
4040 domain: "app.example.com/api",
41- expected: "app__example__com--api.env",
41+ expected: "YXBwLmV4YW1wbGUuY29tL2FwaQ.env",
4242 },
4343 {
4444 name: "complex domain",
4545 domain: "sub.app.example.com:443/v1",
46- expected: "sub__app__example__com-_443--v1.env",
46+ expected: "c3ViLmFwcC5leGFtcGxlLmNvbTo0NDMvdjE.env",
4747 },
4848 {
4949 name: "single word",
5050 domain: "localhost",
51- expected: "localhost.env",
51+ expected: "bG9jYWxob3N0.env",
5252 },
5353 }
5454
5555 for _, tt := range tests {
5656 t.Run(tt.name, func(t *testing.T) {
57- result := domainToEnvFileName(tt.domain)
57+ result, err := domainToEnvFileName(tt.domain)
58+ require.NoError(t, err)
5859 assert.Equal(t, tt.expected, result)
5960 })
6061 }
@@ -28,33 +28,34 @@ func TestDomainToEnvFileName(t *testing.T) {
28 {28 {
29 name: "simple domain",29 name: "simple domain",
30 domain: "app.example.com",30 domain: "app.example.com",
31- expected: "app__example__com.env",31+ expected: "YXBwLmV4YW1wbGUuY29t.env",
32 },32 },
33 {33 {
34 name: "domain with port",34 name: "domain with port",
35 domain: "app.example.com:8080",35 domain: "app.example.com:8080",
36- expected: "app__example__com-_8080.env",36+ expected: "YXBwLmV4YW1wbGUuY29tOjgwODA.env",
37 },37 },
38 {38 {
39 name: "domain with path",39 name: "domain with path",
40 domain: "app.example.com/api",40 domain: "app.example.com/api",
41- expected: "app__example__com--api.env",41+ expected: "YXBwLmV4YW1wbGUuY29tL2FwaQ.env",
42 },42 },
43 {43 {
44 name: "complex domain",44 name: "complex domain",
45 domain: "sub.app.example.com:443/v1",45 domain: "sub.app.example.com:443/v1",
46- expected: "sub__app__example__com-_443--v1.env",46+ expected: "c3ViLmFwcC5leGFtcGxlLmNvbTo0NDMvdjE.env",
47 },47 },
48 {48 {
49 name: "single word",49 name: "single word",
50 domain: "localhost",50 domain: "localhost",
51- expected: "localhost.env",51+ expected: "bG9jYWxob3N0.env",
52 },52 },
53 }53 }
54 54
55 for _, tt := range tests {55 for _, tt := range tests {
56 t.Run(tt.name, func(t *testing.T) {56 t.Run(tt.name, func(t *testing.T) {
57- result := domainToEnvFileName(tt.domain)57+ result, err := domainToEnvFileName(tt.domain)
58+ require.NoError(t, err)
58 assert.Equal(t, tt.expected, result)59 assert.Equal(t, tt.expected, result)
59 })60 })
60 }61 }

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