detached 078b950

fix(review): address Copilot and CodeRabbit PR feedback

commit
078b9507577c5f67082820777642e294255c1ac5
author
bnema · · 2026-01-30 21:45 UTC
parent
3659333
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +179 -13
M docs/config/auth.md +2 -2
@@ -45,7 +45,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive
4545 `token_secret` and `password_hash` are read through the configured backend:
4646
4747 | Backend | Description |
48-|---------|-------------|
48+| --------- | ------------- |
4949 | `pass` | Unix password manager (GPG-encrypted) |
5050 | `sops` | Mozilla SOPS encrypted files |
5151 | `unsafe` | Plain text files (development only) |
@@ -112,7 +112,7 @@ gordon remotes set-token prod <token>
112112 Registry scopes:
113113
114114 | Scope | Permission |
115-|-------|------------|
115+| ------- | ------------ |
116116 | `push` | Push images to registry |
117117 | `pull` | Pull images from registry |
118118 | `push,pull` | Both push and pull (default) |
@@ -45,7 +45,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive
45 `token_secret` and `password_hash` are read through the configured backend:45 `token_secret` and `password_hash` are read through the configured backend:
46 46
47 | Backend | Description |47 | Backend | Description |
48-|---------|-------------|48+| --------- | ------------- |
49 | `pass` | Unix password manager (GPG-encrypted) |49 | `pass` | Unix password manager (GPG-encrypted) |
50 | `sops` | Mozilla SOPS encrypted files |50 | `sops` | Mozilla SOPS encrypted files |
51 | `unsafe` | Plain text files (development only) |51 | `unsafe` | Plain text files (development only) |
@@ -112,7 +112,7 @@ gordon remotes set-token prod <token>
112 Registry scopes:112 Registry scopes:
113 113
114 | Scope | Permission |114 | Scope | Permission |
115-|-------|------------|115+| ------- | ------------ |
116 | `push` | Push images to registry |116 | `push` | Push images to registry |
117 | `pull` | Pull images from registry |117 | `pull` | Pull images from registry |
118 | `push,pull` | Both push and pull (default) |118 | `push,pull` | Both push and pull (default) |
M docs/config/secrets.md +1 -1
@@ -55,7 +55,7 @@ token_secret = "gordon/auth/token_secret"
5555 - Works with team GPG keys
5656
5757 **Route secrets storage:**
58-- `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<domain>/<KEY>`
58+- `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores)
5959 - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated`
6060
6161 ### SOPS
@@ -55,7 +55,7 @@ token_secret = "gordon/auth/token_secret"
55 - Works with team GPG keys55 - Works with team GPG keys
56 56
57 **Route secrets storage:**57 **Route secrets storage:**
58-- `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<domain>/<KEY>`58+- `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores)
59 - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated`59 - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated`
60 60
61 ### SOPS61 ### SOPS
M docs/getting-started.md +1 -0
@@ -54,6 +54,7 @@ openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
5454 ```
5555
5656 **Or set it via environment variable:**
57+
5758 ```bash
5859 export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here"
5960 ```
@@ -54,6 +54,7 @@ openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
54 ```54 ```
55 55
56 **Or set it via environment variable:**56 **Or set it via environment variable:**
57+
57 ```bash58 ```bash
58 export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here"59 export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here"
59 ```60 ```
M internal/adapters/out/domainsecrets/pass_store.go +2 -1
@@ -387,7 +387,8 @@ func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, er
387387 }
388388
389389 clean := ansiRegex.ReplaceAllString(string(output), "")
390- return strings.TrimSpace(clean), true, nil
390+ clean = strings.TrimRight(clean, "\r\n")
391+ return clean, true, nil
391392 }
392393
393394 func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
@@ -387,7 +387,8 @@ func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, er
387 }387 }
388 388
389 clean := ansiRegex.ReplaceAllString(string(output), "")389 clean := ansiRegex.ReplaceAllString(string(output), "")
390- return strings.TrimSpace(clean), true, nil390+ clean = strings.TrimRight(clean, "\r\n")
391+ return clean, true, nil
391 }392 }
392 393
393 func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {394 func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
M internal/adapters/out/domainsecrets/pass_store_test.go +14 -8
@@ -1,9 +1,9 @@
11 package domainsecrets
22
33 import (
4+ "context"
45 "fmt"
56 "os/exec"
6- "strings"
77 "testing"
88 "time"
99
@@ -13,16 +13,22 @@ import (
1313 "github.com/bnema/gordon/internal/domain"
1414 )
1515
16+func passCmd(args ...string) error {
17+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
18+ defer cancel()
19+ return exec.CommandContext(ctx, "pass", args...).Run()
20+}
21+
1622 func requirePass(t *testing.T) {
17- if err := exec.Command("pass", "version").Run(); err != nil {
23+ if err := passCmd("version"); err != nil {
1824 t.Skip("pass not available")
1925 }
20- if err := exec.Command("pass", "ls").Run(); err != nil {
26+ if err := passCmd("ls"); err != nil {
2127 t.Skip("pass store not initialized")
2228 }
2329 }
2430
25-func cleanupPassDomain(t *testing.T, domainName string, keys []string) {
31+func cleanupPassDomain(_ *testing.T, domainName string, keys []string) {
2632 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
2733 if err != nil {
2834 return
@@ -30,11 +36,11 @@ func cleanupPassDomain(t *testing.T, domainName string, keys []string) {
3036
3137 for _, key := range keys {
3238 path := fmt.Sprintf("%s/%s/%s", passDomainSecretsPath, safeDomain, key)
33- _ = exec.Command("pass", "rm", "-f", path).Run()
39+ _ = passCmd("rm", "-f", path)
3440 }
3541
3642 manifestPath := fmt.Sprintf("%s/%s/.keys", passDomainSecretsPath, safeDomain)
37- _ = exec.Command("pass", "rm", "-f", manifestPath).Run()
43+ _ = passCmd("rm", "-f", manifestPath)
3844 }
3945
4046 func TestPassStore_SetGetDelete(t *testing.T) {
@@ -62,8 +68,8 @@ func TestPassStore_SetGetDelete(t *testing.T) {
6268
6369 values, err := store.GetAll(domainName)
6470 require.NoError(t, err)
65- assert.Equal(t, "alpha", strings.TrimSpace(values["API_KEY"]))
66- assert.Equal(t, "bravo", strings.TrimSpace(values["DB_PASSWORD"]))
71+ assert.Equal(t, "alpha", values["API_KEY"])
72+ assert.Equal(t, "bravo", values["DB_PASSWORD"])
6773
6874 err = store.Delete(domainName, "DB_PASSWORD")
6975 require.NoError(t, err)
@@ -1,9 +1,9 @@
1 package domainsecrets1 package domainsecrets
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "os/exec"6 "os/exec"
6- "strings"
7 "testing"7 "testing"
8 "time"8 "time"
9 9
@@ -13,16 +13,22 @@ import (
13 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
14 )14 )
15 15
16+func passCmd(args ...string) error {
17+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
18+ defer cancel()
19+ return exec.CommandContext(ctx, "pass", args...).Run()
20+}
21+
16 func requirePass(t *testing.T) {22 func requirePass(t *testing.T) {
17- if err := exec.Command("pass", "version").Run(); err != nil {23+ if err := passCmd("version"); err != nil {
18 t.Skip("pass not available")24 t.Skip("pass not available")
19 }25 }
20- if err := exec.Command("pass", "ls").Run(); err != nil {26+ if err := passCmd("ls"); err != nil {
21 t.Skip("pass store not initialized")27 t.Skip("pass store not initialized")
22 }28 }
23 }29 }
24 30
25-func cleanupPassDomain(t *testing.T, domainName string, keys []string) {31+func cleanupPassDomain(_ *testing.T, domainName string, keys []string) {
26 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)32 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
27 if err != nil {33 if err != nil {
28 return34 return
@@ -30,11 +36,11 @@ func cleanupPassDomain(t *testing.T, domainName string, keys []string) {
30 36
31 for _, key := range keys {37 for _, key := range keys {
32 path := fmt.Sprintf("%s/%s/%s", passDomainSecretsPath, safeDomain, key)38 path := fmt.Sprintf("%s/%s/%s", passDomainSecretsPath, safeDomain, key)
33- _ = exec.Command("pass", "rm", "-f", path).Run()39+ _ = passCmd("rm", "-f", path)
34 }40 }
35 41
36 manifestPath := fmt.Sprintf("%s/%s/.keys", passDomainSecretsPath, safeDomain)42 manifestPath := fmt.Sprintf("%s/%s/.keys", passDomainSecretsPath, safeDomain)
37- _ = exec.Command("pass", "rm", "-f", manifestPath).Run()43+ _ = passCmd("rm", "-f", manifestPath)
38 }44 }
39 45
40 func TestPassStore_SetGetDelete(t *testing.T) {46 func TestPassStore_SetGetDelete(t *testing.T) {
@@ -62,8 +68,8 @@ func TestPassStore_SetGetDelete(t *testing.T) {
62 68
63 values, err := store.GetAll(domainName)69 values, err := store.GetAll(domainName)
64 require.NoError(t, err)70 require.NoError(t, err)
65- assert.Equal(t, "alpha", strings.TrimSpace(values["API_KEY"]))71+ assert.Equal(t, "alpha", values["API_KEY"])
66- assert.Equal(t, "bravo", strings.TrimSpace(values["DB_PASSWORD"]))72+ assert.Equal(t, "bravo", values["DB_PASSWORD"])
67 73
68 err = store.Delete(domainName, "DB_PASSWORD")74 err = store.Delete(domainName, "DB_PASSWORD")
69 require.NoError(t, err)75 require.NoError(t, err)
M internal/app/migrate_env.go +8 -1
@@ -46,7 +46,14 @@ func readEnvDir(envDir string) ([]os.DirEntry, error) {
4646 }
4747
4848 func isPlainEnvFile(name string) bool {
49- return strings.HasSuffix(name, ".env") && !strings.HasSuffix(name, ".env.migrated")
49+ if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") {
50+ return false
51+ }
52+ // Skip attachment env files: gordon-<sanitized-domain>-<service>.env
53+ if strings.HasPrefix(name, "gordon-") {
54+ return false
55+ }
56+ return true
5057 }
5158
5259 func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
@@ -46,7 +46,14 @@ func readEnvDir(envDir string) ([]os.DirEntry, error) {
46 }46 }
47 47
48 func isPlainEnvFile(name string) bool {48 func isPlainEnvFile(name string) bool {
49- return strings.HasSuffix(name, ".env") && !strings.HasSuffix(name, ".env.migrated")49+ if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") {
50+ return false
51+ }
52+ // Skip attachment env files: gordon-<sanitized-domain>-<service>.env
53+ if strings.HasPrefix(name, "gordon-") {
54+ return false
55+ }
56+ return true
50 }57 }
51 58
52 func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {59 func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
M internal/domain/env.go +2 -0
@@ -37,6 +37,8 @@ func SanitizeDomainForEnvFile(domainName string) (string, error) {
3737 func ParseEnvData(data []byte) (map[string]string, error) {
3838 secrets := make(map[string]string)
3939 scanner := bufio.NewScanner(bytes.NewReader(data))
40+ // Allow large env values (certs/keys) without hitting the default ~64KB limit.
41+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
4042 for scanner.Scan() {
4143 line := strings.TrimSpace(scanner.Text())
4244 if line == "" || strings.HasPrefix(line, "#") {
@@ -37,6 +37,8 @@ func SanitizeDomainForEnvFile(domainName string) (string, error) {
37 func ParseEnvData(data []byte) (map[string]string, error) {37 func ParseEnvData(data []byte) (map[string]string, error) {
38 secrets := make(map[string]string)38 secrets := make(map[string]string)
39 scanner := bufio.NewScanner(bytes.NewReader(data))39 scanner := bufio.NewScanner(bytes.NewReader(data))
40+ // Allow large env values (certs/keys) without hitting the default ~64KB limit.
41+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
40 for scanner.Scan() {42 for scanner.Scan() {
41 line := strings.TrimSpace(scanner.Text())43 line := strings.TrimSpace(scanner.Text())
42 if line == "" || strings.HasPrefix(line, "#") {44 if line == "" || strings.HasPrefix(line, "#") {
A internal/domain/env_test.go +149 -0
@@ -0,0 +1,149 @@
1+package domain
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestSanitizeDomainForEnvFile(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ domain string
15+ want string
16+ wantErr bool
17+ }{
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"},
24+ {name: "empty", domain: "", wantErr: true},
25+ {name: "path traversal", domain: "../etc/passwd", wantErr: true},
26+ {name: "double dots", domain: "foo..bar", wantErr: true},
27+ {name: "starts with dot", domain: ".hidden", wantErr: true},
28+ {name: "ends with dot", domain: "trailing.", wantErr: true},
29+ {name: "space", domain: "has space", wantErr: true},
30+ {name: "special chars", domain: "bad$domain", wantErr: true},
31+ }
32+
33+ for _, tt := range tests {
34+ t.Run(tt.name, func(t *testing.T) {
35+ got, err := SanitizeDomainForEnvFile(tt.domain)
36+ if tt.wantErr {
37+ assert.Error(t, err)
38+ return
39+ }
40+ require.NoError(t, err)
41+ assert.Equal(t, tt.want, got)
42+ })
43+ }
44+}
45+
46+func TestParseEnvData(t *testing.T) {
47+ tests := []struct {
48+ name string
49+ data string
50+ want map[string]string
51+ wantErr bool
52+ }{
53+ {
54+ name: "simple key-value",
55+ data: "FOO=bar\nBAZ=qux",
56+ want: map[string]string{"FOO": "bar", "BAZ": "qux"},
57+ },
58+ {
59+ name: "double-quoted value",
60+ data: `KEY="hello world"`,
61+ want: map[string]string{"KEY": "hello world"},
62+ },
63+ {
64+ name: "single-quoted value",
65+ data: `KEY='hello world'`,
66+ want: map[string]string{"KEY": "hello world"},
67+ },
68+ {
69+ name: "comment lines",
70+ data: "# this is a comment\nKEY=val",
71+ want: map[string]string{"KEY": "val"},
72+ },
73+ {
74+ name: "empty lines",
75+ data: "\n\nKEY=val\n\n",
76+ want: map[string]string{"KEY": "val"},
77+ },
78+ {
79+ name: "value with equals",
80+ data: "URL=postgres://host:5432/db?opt=1",
81+ want: map[string]string{"URL": "postgres://host:5432/db?opt=1"},
82+ },
83+ {
84+ name: "no value",
85+ data: "NOVALUE",
86+ want: map[string]string{},
87+ },
88+ {
89+ name: "empty value",
90+ data: "KEY=",
91+ want: map[string]string{"KEY": ""},
92+ },
93+ {
94+ name: "empty input",
95+ data: "",
96+ want: map[string]string{},
97+ },
98+ {
99+ name: "large value within buffer",
100+ data: "BIG=" + strings.Repeat("x", 100000),
101+ want: map[string]string{"BIG": strings.Repeat("x", 100000)},
102+ },
103+ }
104+
105+ for _, tt := range tests {
106+ t.Run(tt.name, func(t *testing.T) {
107+ got, err := ParseEnvData([]byte(tt.data))
108+ if tt.wantErr {
109+ assert.Error(t, err)
110+ return
111+ }
112+ require.NoError(t, err)
113+ assert.Equal(t, tt.want, got)
114+ })
115+ }
116+}
117+
118+func TestValidateEnvKey(t *testing.T) {
119+ tests := []struct {
120+ name string
121+ key string
122+ wantErr bool
123+ }{
124+ {name: "simple", key: "FOO", wantErr: false},
125+ {name: "with underscore", key: "FOO_BAR", wantErr: false},
126+ {name: "starts with underscore", key: "_PRIVATE", wantErr: false},
127+ {name: "lowercase", key: "foo", wantErr: false},
128+ {name: "mixed", key: "myApp_v2", wantErr: false},
129+ {name: "empty", key: "", wantErr: true},
130+ {name: "starts with number", key: "1BAD", wantErr: true},
131+ {name: "contains dot", key: "FOO.BAR", wantErr: true},
132+ {name: "contains slash", key: "FOO/BAR", wantErr: true},
133+ {name: "contains backslash", key: `FOO\BAR`, wantErr: true},
134+ {name: "path traversal", key: "..", wantErr: true},
135+ {name: "contains hyphen", key: "FOO-BAR", wantErr: true},
136+ {name: "contains space", key: "FOO BAR", wantErr: true},
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ err := ValidateEnvKey(tt.key)
142+ if tt.wantErr {
143+ assert.Error(t, err)
144+ } else {
145+ assert.NoError(t, err)
146+ }
147+ })
148+ }
149+}
@@ -0,0 +1,149 @@
1+package domain
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestSanitizeDomainForEnvFile(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ domain string
15+ want string
16+ wantErr bool
17+ }{
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"},
24+ {name: "empty", domain: "", wantErr: true},
25+ {name: "path traversal", domain: "../etc/passwd", wantErr: true},
26+ {name: "double dots", domain: "foo..bar", wantErr: true},
27+ {name: "starts with dot", domain: ".hidden", wantErr: true},
28+ {name: "ends with dot", domain: "trailing.", wantErr: true},
29+ {name: "space", domain: "has space", wantErr: true},
30+ {name: "special chars", domain: "bad$domain", wantErr: true},
31+ }
32+
33+ for _, tt := range tests {
34+ t.Run(tt.name, func(t *testing.T) {
35+ got, err := SanitizeDomainForEnvFile(tt.domain)
36+ if tt.wantErr {
37+ assert.Error(t, err)
38+ return
39+ }
40+ require.NoError(t, err)
41+ assert.Equal(t, tt.want, got)
42+ })
43+ }
44+}
45+
46+func TestParseEnvData(t *testing.T) {
47+ tests := []struct {
48+ name string
49+ data string
50+ want map[string]string
51+ wantErr bool
52+ }{
53+ {
54+ name: "simple key-value",
55+ data: "FOO=bar\nBAZ=qux",
56+ want: map[string]string{"FOO": "bar", "BAZ": "qux"},
57+ },
58+ {
59+ name: "double-quoted value",
60+ data: `KEY="hello world"`,
61+ want: map[string]string{"KEY": "hello world"},
62+ },
63+ {
64+ name: "single-quoted value",
65+ data: `KEY='hello world'`,
66+ want: map[string]string{"KEY": "hello world"},
67+ },
68+ {
69+ name: "comment lines",
70+ data: "# this is a comment\nKEY=val",
71+ want: map[string]string{"KEY": "val"},
72+ },
73+ {
74+ name: "empty lines",
75+ data: "\n\nKEY=val\n\n",
76+ want: map[string]string{"KEY": "val"},
77+ },
78+ {
79+ name: "value with equals",
80+ data: "URL=postgres://host:5432/db?opt=1",
81+ want: map[string]string{"URL": "postgres://host:5432/db?opt=1"},
82+ },
83+ {
84+ name: "no value",
85+ data: "NOVALUE",
86+ want: map[string]string{},
87+ },
88+ {
89+ name: "empty value",
90+ data: "KEY=",
91+ want: map[string]string{"KEY": ""},
92+ },
93+ {
94+ name: "empty input",
95+ data: "",
96+ want: map[string]string{},
97+ },
98+ {
99+ name: "large value within buffer",
100+ data: "BIG=" + strings.Repeat("x", 100000),
101+ want: map[string]string{"BIG": strings.Repeat("x", 100000)},
102+ },
103+ }
104+
105+ for _, tt := range tests {
106+ t.Run(tt.name, func(t *testing.T) {
107+ got, err := ParseEnvData([]byte(tt.data))
108+ if tt.wantErr {
109+ assert.Error(t, err)
110+ return
111+ }
112+ require.NoError(t, err)
113+ assert.Equal(t, tt.want, got)
114+ })
115+ }
116+}
117+
118+func TestValidateEnvKey(t *testing.T) {
119+ tests := []struct {
120+ name string
121+ key string
122+ wantErr bool
123+ }{
124+ {name: "simple", key: "FOO", wantErr: false},
125+ {name: "with underscore", key: "FOO_BAR", wantErr: false},
126+ {name: "starts with underscore", key: "_PRIVATE", wantErr: false},
127+ {name: "lowercase", key: "foo", wantErr: false},
128+ {name: "mixed", key: "myApp_v2", wantErr: false},
129+ {name: "empty", key: "", wantErr: true},
130+ {name: "starts with number", key: "1BAD", wantErr: true},
131+ {name: "contains dot", key: "FOO.BAR", wantErr: true},
132+ {name: "contains slash", key: "FOO/BAR", wantErr: true},
133+ {name: "contains backslash", key: `FOO\BAR`, wantErr: true},
134+ {name: "path traversal", key: "..", wantErr: true},
135+ {name: "contains hyphen", key: "FOO-BAR", wantErr: true},
136+ {name: "contains space", key: "FOO BAR", wantErr: true},
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ err := ValidateEnvKey(tt.key)
142+ if tt.wantErr {
143+ assert.Error(t, err)
144+ } else {
145+ assert.NoError(t, err)
146+ }
147+ })
148+ }
149+}

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