detached 4259e72

fix(secrets): convert dot notation to sops extract path syntax

The sops CLI requires Python dictionary syntax for --extract
(e.g., ["app"]["secret"]), but the provider was passing dot notation
as a single key (["app.secret"]).

Added convertToSopsExtractPath() to properly convert:
- app.secret -> ["app"]["secret"]
- items[0].name -> ["items"][0]["name"]

Also adds unit tests for the sops path conversion and FileLoader
secret resolution using MockSecretProvider.
commit
4259e725337735d20c7746e14466e1c480b844fa
author
brice · · 2026-01-13 16:36 UTC
parent
620c0ba
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +345 -1
A internal/adapters/out/envloader/file_test.go +219 -0
@@ -0,0 +1,219 @@
1+package envloader
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "gordon/internal/boundaries/out/mocks"
14+)
15+
16+func TestFileLoader_ResolveSecrets(t *testing.T) {
17+ // Create a temp directory for tests
18+ tmpDir := t.TempDir()
19+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
20+
21+ loader, err := NewFileLoader(tmpDir, log)
22+ require.NoError(t, err)
23+
24+ ctx := context.Background()
25+
26+ t.Run("no secret syntax returns value unchanged", func(t *testing.T) {
27+ result, err := loader.resolveSecrets(ctx, "plain-value")
28+ assert.NoError(t, err)
29+ assert.Equal(t, "plain-value", result)
30+ })
31+
32+ t.Run("unclosed secret syntax returns error", func(t *testing.T) {
33+ _, err := loader.resolveSecrets(ctx, "${pass:secret")
34+ assert.Error(t, err)
35+ assert.Contains(t, err.Error(), "unclosed secret syntax")
36+ })
37+
38+ t.Run("invalid secret syntax without colon returns error", func(t *testing.T) {
39+ _, err := loader.resolveSecrets(ctx, "${invalid}")
40+ assert.Error(t, err)
41+ assert.Contains(t, err.Error(), "invalid secret syntax")
42+ })
43+
44+ t.Run("unknown provider returns error", func(t *testing.T) {
45+ _, err := loader.resolveSecrets(ctx, "${unknown:path}")
46+ assert.Error(t, err)
47+ assert.Contains(t, err.Error(), "unknown secret provider")
48+ })
49+
50+ t.Run("pass provider resolves secret", func(t *testing.T) {
51+ mockPass := mocks.NewMockSecretProvider(t)
52+ mockPass.EXPECT().Name().Return("pass")
53+ mockPass.EXPECT().GetSecret(ctx, "company/api-key").Return("secret-value-123", nil)
54+
55+ loader.RegisterSecretProvider(mockPass)
56+
57+ result, err := loader.resolveSecrets(ctx, "${pass:company/api-key}")
58+ assert.NoError(t, err)
59+ assert.Equal(t, "secret-value-123", result)
60+ })
61+
62+ t.Run("sops provider resolves secret", func(t *testing.T) {
63+ mockSops := mocks.NewMockSecretProvider(t)
64+ mockSops.EXPECT().Name().Return("sops")
65+ mockSops.EXPECT().GetSecret(ctx, "secrets.yaml:app.password").Return("sops-secret", nil)
66+
67+ loader.RegisterSecretProvider(mockSops)
68+
69+ result, err := loader.resolveSecrets(ctx, "${sops:secrets.yaml:app.password}")
70+ assert.NoError(t, err)
71+ assert.Equal(t, "sops-secret", result)
72+ })
73+
74+ t.Run("multiple secrets in one value", func(t *testing.T) {
75+ mockPass := mocks.NewMockSecretProvider(t)
76+ mockPass.EXPECT().Name().Return("pass")
77+ mockPass.EXPECT().GetSecret(ctx, "db/user").Return("admin", nil)
78+ mockPass.EXPECT().GetSecret(ctx, "db/pass").Return("secret123", nil)
79+
80+ loader.RegisterSecretProvider(mockPass)
81+
82+ result, err := loader.resolveSecrets(ctx, "postgresql://${pass:db/user}:${pass:db/pass}@localhost:5432/db")
83+ assert.NoError(t, err)
84+ assert.Equal(t, "postgresql://admin:secret123@localhost:5432/db", result)
85+ })
86+
87+ t.Run("provider error is propagated", func(t *testing.T) {
88+ mockPass := mocks.NewMockSecretProvider(t)
89+ mockPass.EXPECT().Name().Return("pass")
90+ mockPass.EXPECT().GetSecret(ctx, "missing/secret").Return("", assert.AnError)
91+
92+ loader.RegisterSecretProvider(mockPass)
93+
94+ _, err := loader.resolveSecrets(ctx, "${pass:missing/secret}")
95+ assert.Error(t, err)
96+ assert.Contains(t, err.Error(), "failed to get secret from provider")
97+ })
98+}
99+
100+func TestFileLoader_LoadEnv(t *testing.T) {
101+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
102+ ctx := context.Background()
103+
104+ t.Run("missing env file returns empty slice", func(t *testing.T) {
105+ tmpDir := t.TempDir()
106+ loader, err := NewFileLoader(tmpDir, log)
107+ require.NoError(t, err)
108+
109+ envVars, err := loader.LoadEnv(ctx, "nonexistent.example.com")
110+ assert.NoError(t, err)
111+ assert.Empty(t, envVars)
112+ })
113+
114+ t.Run("loads simple env file", func(t *testing.T) {
115+ tmpDir := t.TempDir()
116+ loader, err := NewFileLoader(tmpDir, log)
117+ require.NoError(t, err)
118+
119+ // Create env file
120+ envContent := `# Comment line
121+NODE_ENV=production
122+API_URL=https://api.example.com
123+`
124+ envFile := filepath.Join(tmpDir, "app_example_com.env")
125+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
126+
127+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
128+ assert.NoError(t, err)
129+ assert.Len(t, envVars, 2)
130+ assert.Contains(t, envVars, "NODE_ENV=production")
131+ assert.Contains(t, envVars, "API_URL=https://api.example.com")
132+ })
133+
134+ t.Run("loads env file with secrets", func(t *testing.T) {
135+ tmpDir := t.TempDir()
136+ loader, err := NewFileLoader(tmpDir, log)
137+ require.NoError(t, err)
138+
139+ // Register mock provider
140+ mockPass := mocks.NewMockSecretProvider(t)
141+ mockPass.EXPECT().Name().Return("pass")
142+ mockPass.EXPECT().GetSecret(ctx, "app/api-key").Return("super-secret-key", nil)
143+ loader.RegisterSecretProvider(mockPass)
144+
145+ // Create env file with secret reference
146+ envContent := `NODE_ENV=production
147+API_KEY=${pass:app/api-key}
148+`
149+ envFile := filepath.Join(tmpDir, "app_example_com.env")
150+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
151+
152+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
153+ assert.NoError(t, err)
154+ assert.Len(t, envVars, 2)
155+ assert.Contains(t, envVars, "NODE_ENV=production")
156+ assert.Contains(t, envVars, "API_KEY=super-secret-key")
157+ })
158+
159+ t.Run("handles quoted values", func(t *testing.T) {
160+ tmpDir := t.TempDir()
161+ loader, err := NewFileLoader(tmpDir, log)
162+ require.NoError(t, err)
163+
164+ envContent := `DOUBLE_QUOTED="hello world"
165+SINGLE_QUOTED='hello world'
166+`
167+ envFile := filepath.Join(tmpDir, "app_example_com.env")
168+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
169+
170+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
171+ assert.NoError(t, err)
172+ assert.Len(t, envVars, 2)
173+ assert.Contains(t, envVars, "DOUBLE_QUOTED=hello world")
174+ assert.Contains(t, envVars, "SINGLE_QUOTED=hello world")
175+ })
176+
177+ t.Run("skips invalid lines", func(t *testing.T) {
178+ tmpDir := t.TempDir()
179+ loader, err := NewFileLoader(tmpDir, log)
180+ require.NoError(t, err)
181+
182+ envContent := `VALID=value
183+invalid line without equals
184+ALSO_VALID=another
185+`
186+ envFile := filepath.Join(tmpDir, "app_example_com.env")
187+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
188+
189+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
190+ assert.NoError(t, err)
191+ assert.Len(t, envVars, 2)
192+ assert.Contains(t, envVars, "VALID=value")
193+ assert.Contains(t, envVars, "ALSO_VALID=another")
194+ })
195+}
196+
197+func TestFileLoader_GetEnvFilePath(t *testing.T) {
198+ tmpDir := t.TempDir()
199+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
200+
201+ loader, err := NewFileLoader(tmpDir, log)
202+ require.NoError(t, err)
203+
204+ tests := []struct {
205+ domain string
206+ expected string
207+ }{
208+ {"app.example.com", "app_example_com.env"},
209+ {"api.sub.example.com", "api_sub_example_com.env"},
210+ {"localhost:8080", "localhost_8080.env"},
211+ }
212+
213+ for _, tt := range tests {
214+ t.Run(tt.domain, func(t *testing.T) {
215+ result := loader.getEnvFilePath(tt.domain)
216+ assert.Equal(t, filepath.Join(tmpDir, tt.expected), result)
217+ })
218+ }
219+}
@@ -0,0 +1,219 @@
1+package envloader
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "gordon/internal/boundaries/out/mocks"
14+)
15+
16+func TestFileLoader_ResolveSecrets(t *testing.T) {
17+ // Create a temp directory for tests
18+ tmpDir := t.TempDir()
19+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
20+
21+ loader, err := NewFileLoader(tmpDir, log)
22+ require.NoError(t, err)
23+
24+ ctx := context.Background()
25+
26+ t.Run("no secret syntax returns value unchanged", func(t *testing.T) {
27+ result, err := loader.resolveSecrets(ctx, "plain-value")
28+ assert.NoError(t, err)
29+ assert.Equal(t, "plain-value", result)
30+ })
31+
32+ t.Run("unclosed secret syntax returns error", func(t *testing.T) {
33+ _, err := loader.resolveSecrets(ctx, "${pass:secret")
34+ assert.Error(t, err)
35+ assert.Contains(t, err.Error(), "unclosed secret syntax")
36+ })
37+
38+ t.Run("invalid secret syntax without colon returns error", func(t *testing.T) {
39+ _, err := loader.resolveSecrets(ctx, "${invalid}")
40+ assert.Error(t, err)
41+ assert.Contains(t, err.Error(), "invalid secret syntax")
42+ })
43+
44+ t.Run("unknown provider returns error", func(t *testing.T) {
45+ _, err := loader.resolveSecrets(ctx, "${unknown:path}")
46+ assert.Error(t, err)
47+ assert.Contains(t, err.Error(), "unknown secret provider")
48+ })
49+
50+ t.Run("pass provider resolves secret", func(t *testing.T) {
51+ mockPass := mocks.NewMockSecretProvider(t)
52+ mockPass.EXPECT().Name().Return("pass")
53+ mockPass.EXPECT().GetSecret(ctx, "company/api-key").Return("secret-value-123", nil)
54+
55+ loader.RegisterSecretProvider(mockPass)
56+
57+ result, err := loader.resolveSecrets(ctx, "${pass:company/api-key}")
58+ assert.NoError(t, err)
59+ assert.Equal(t, "secret-value-123", result)
60+ })
61+
62+ t.Run("sops provider resolves secret", func(t *testing.T) {
63+ mockSops := mocks.NewMockSecretProvider(t)
64+ mockSops.EXPECT().Name().Return("sops")
65+ mockSops.EXPECT().GetSecret(ctx, "secrets.yaml:app.password").Return("sops-secret", nil)
66+
67+ loader.RegisterSecretProvider(mockSops)
68+
69+ result, err := loader.resolveSecrets(ctx, "${sops:secrets.yaml:app.password}")
70+ assert.NoError(t, err)
71+ assert.Equal(t, "sops-secret", result)
72+ })
73+
74+ t.Run("multiple secrets in one value", func(t *testing.T) {
75+ mockPass := mocks.NewMockSecretProvider(t)
76+ mockPass.EXPECT().Name().Return("pass")
77+ mockPass.EXPECT().GetSecret(ctx, "db/user").Return("admin", nil)
78+ mockPass.EXPECT().GetSecret(ctx, "db/pass").Return("secret123", nil)
79+
80+ loader.RegisterSecretProvider(mockPass)
81+
82+ result, err := loader.resolveSecrets(ctx, "postgresql://${pass:db/user}:${pass:db/pass}@localhost:5432/db")
83+ assert.NoError(t, err)
84+ assert.Equal(t, "postgresql://admin:secret123@localhost:5432/db", result)
85+ })
86+
87+ t.Run("provider error is propagated", func(t *testing.T) {
88+ mockPass := mocks.NewMockSecretProvider(t)
89+ mockPass.EXPECT().Name().Return("pass")
90+ mockPass.EXPECT().GetSecret(ctx, "missing/secret").Return("", assert.AnError)
91+
92+ loader.RegisterSecretProvider(mockPass)
93+
94+ _, err := loader.resolveSecrets(ctx, "${pass:missing/secret}")
95+ assert.Error(t, err)
96+ assert.Contains(t, err.Error(), "failed to get secret from provider")
97+ })
98+}
99+
100+func TestFileLoader_LoadEnv(t *testing.T) {
101+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
102+ ctx := context.Background()
103+
104+ t.Run("missing env file returns empty slice", func(t *testing.T) {
105+ tmpDir := t.TempDir()
106+ loader, err := NewFileLoader(tmpDir, log)
107+ require.NoError(t, err)
108+
109+ envVars, err := loader.LoadEnv(ctx, "nonexistent.example.com")
110+ assert.NoError(t, err)
111+ assert.Empty(t, envVars)
112+ })
113+
114+ t.Run("loads simple env file", func(t *testing.T) {
115+ tmpDir := t.TempDir()
116+ loader, err := NewFileLoader(tmpDir, log)
117+ require.NoError(t, err)
118+
119+ // Create env file
120+ envContent := `# Comment line
121+NODE_ENV=production
122+API_URL=https://api.example.com
123+`
124+ envFile := filepath.Join(tmpDir, "app_example_com.env")
125+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
126+
127+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
128+ assert.NoError(t, err)
129+ assert.Len(t, envVars, 2)
130+ assert.Contains(t, envVars, "NODE_ENV=production")
131+ assert.Contains(t, envVars, "API_URL=https://api.example.com")
132+ })
133+
134+ t.Run("loads env file with secrets", func(t *testing.T) {
135+ tmpDir := t.TempDir()
136+ loader, err := NewFileLoader(tmpDir, log)
137+ require.NoError(t, err)
138+
139+ // Register mock provider
140+ mockPass := mocks.NewMockSecretProvider(t)
141+ mockPass.EXPECT().Name().Return("pass")
142+ mockPass.EXPECT().GetSecret(ctx, "app/api-key").Return("super-secret-key", nil)
143+ loader.RegisterSecretProvider(mockPass)
144+
145+ // Create env file with secret reference
146+ envContent := `NODE_ENV=production
147+API_KEY=${pass:app/api-key}
148+`
149+ envFile := filepath.Join(tmpDir, "app_example_com.env")
150+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
151+
152+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
153+ assert.NoError(t, err)
154+ assert.Len(t, envVars, 2)
155+ assert.Contains(t, envVars, "NODE_ENV=production")
156+ assert.Contains(t, envVars, "API_KEY=super-secret-key")
157+ })
158+
159+ t.Run("handles quoted values", func(t *testing.T) {
160+ tmpDir := t.TempDir()
161+ loader, err := NewFileLoader(tmpDir, log)
162+ require.NoError(t, err)
163+
164+ envContent := `DOUBLE_QUOTED="hello world"
165+SINGLE_QUOTED='hello world'
166+`
167+ envFile := filepath.Join(tmpDir, "app_example_com.env")
168+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
169+
170+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
171+ assert.NoError(t, err)
172+ assert.Len(t, envVars, 2)
173+ assert.Contains(t, envVars, "DOUBLE_QUOTED=hello world")
174+ assert.Contains(t, envVars, "SINGLE_QUOTED=hello world")
175+ })
176+
177+ t.Run("skips invalid lines", func(t *testing.T) {
178+ tmpDir := t.TempDir()
179+ loader, err := NewFileLoader(tmpDir, log)
180+ require.NoError(t, err)
181+
182+ envContent := `VALID=value
183+invalid line without equals
184+ALSO_VALID=another
185+`
186+ envFile := filepath.Join(tmpDir, "app_example_com.env")
187+ require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
188+
189+ envVars, err := loader.LoadEnv(ctx, "app.example.com")
190+ assert.NoError(t, err)
191+ assert.Len(t, envVars, 2)
192+ assert.Contains(t, envVars, "VALID=value")
193+ assert.Contains(t, envVars, "ALSO_VALID=another")
194+ })
195+}
196+
197+func TestFileLoader_GetEnvFilePath(t *testing.T) {
198+ tmpDir := t.TempDir()
199+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
200+
201+ loader, err := NewFileLoader(tmpDir, log)
202+ require.NoError(t, err)
203+
204+ tests := []struct {
205+ domain string
206+ expected string
207+ }{
208+ {"app.example.com", "app_example_com.env"},
209+ {"api.sub.example.com", "api_sub_example_com.env"},
210+ {"localhost:8080", "localhost_8080.env"},
211+ }
212+
213+ for _, tt := range tests {
214+ t.Run(tt.domain, func(t *testing.T) {
215+ result := loader.getEnvFilePath(tt.domain)
216+ assert.Equal(t, filepath.Join(tmpDir, tt.expected), result)
217+ })
218+ }
219+}
M internal/adapters/out/secrets/sops.go +43 -1
@@ -15,6 +15,45 @@ import (
1515 // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets).
1616 var sopsKeyPathRegex = regexp.MustCompile(`^[a-zA-Z0-9._\-\[\]]+$`)
1717
18+// convertToSopsExtractPath converts dot notation (app.database.password) to
19+// sops --extract Python dictionary syntax (["app"]["database"]["password"]).
20+// Array indices like [0] are preserved as-is.
21+func convertToSopsExtractPath(keyPath string) string {
22+ if keyPath == "" {
23+ return ""
24+ }
25+
26+ var result strings.Builder
27+ parts := strings.Split(keyPath, ".")
28+
29+ for _, part := range parts {
30+ if part == "" {
31+ continue
32+ }
33+ // Check if the part already contains array access like "items[0]"
34+ // Split on '[' to handle cases like "items[0]" -> ["items"] + [0]
35+ if idx := strings.Index(part, "["); idx > 0 {
36+ // Has both key and array index, e.g., "items[0]"
37+ key := part[:idx]
38+ arrayPart := part[idx:] // includes "[0]" or "[0][1]" etc.
39+ result.WriteString(`["`)
40+ result.WriteString(key)
41+ result.WriteString(`"]`)
42+ result.WriteString(arrayPart)
43+ } else if strings.HasPrefix(part, "[") {
44+ // Pure array index like "[0]"
45+ result.WriteString(part)
46+ } else {
47+ // Regular key
48+ result.WriteString(`["`)
49+ result.WriteString(part)
50+ result.WriteString(`"]`)
51+ }
52+ }
53+
54+ return result.String()
55+}
56+
1857 // SopsProvider implements the SecretProvider interface using sops.
1958 type SopsProvider struct {
2059 timeout time.Duration
@@ -60,9 +99,12 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro
6099 return "", fmt.Errorf("invalid key path: must contain only alphanumeric characters, dots, underscores, hyphens, and brackets")
61100 }
62101
102+ // Convert dot notation to sops --extract Python dictionary syntax
103+ extractPath := convertToSopsExtractPath(keyPath)
104+
63105 // Use sops -d --extract to get specific key
64106 // #nosec G204 - filePath and keyPath are validated above
65- cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", fmt.Sprintf("[\"%s\"]", keyPath), cleanPath)
107+ cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)
66108 output, err := cmd.Output()
67109 if err != nil {
68110 if exitError, ok := err.(*exec.ExitError); ok {
@@ -15,6 +15,45 @@ import (
15 // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets).15 // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets).
16 var sopsKeyPathRegex = regexp.MustCompile(`^[a-zA-Z0-9._\-\[\]]+$`)16 var sopsKeyPathRegex = regexp.MustCompile(`^[a-zA-Z0-9._\-\[\]]+$`)
17 17
18+// convertToSopsExtractPath converts dot notation (app.database.password) to
19+// sops --extract Python dictionary syntax (["app"]["database"]["password"]).
20+// Array indices like [0] are preserved as-is.
21+func convertToSopsExtractPath(keyPath string) string {
22+ if keyPath == "" {
23+ return ""
24+ }
25+
26+ var result strings.Builder
27+ parts := strings.Split(keyPath, ".")
28+
29+ for _, part := range parts {
30+ if part == "" {
31+ continue
32+ }
33+ // Check if the part already contains array access like "items[0]"
34+ // Split on '[' to handle cases like "items[0]" -> ["items"] + [0]
35+ if idx := strings.Index(part, "["); idx > 0 {
36+ // Has both key and array index, e.g., "items[0]"
37+ key := part[:idx]
38+ arrayPart := part[idx:] // includes "[0]" or "[0][1]" etc.
39+ result.WriteString(`["`)
40+ result.WriteString(key)
41+ result.WriteString(`"]`)
42+ result.WriteString(arrayPart)
43+ } else if strings.HasPrefix(part, "[") {
44+ // Pure array index like "[0]"
45+ result.WriteString(part)
46+ } else {
47+ // Regular key
48+ result.WriteString(`["`)
49+ result.WriteString(part)
50+ result.WriteString(`"]`)
51+ }
52+ }
53+
54+ return result.String()
55+}
56+
18 // SopsProvider implements the SecretProvider interface using sops.57 // SopsProvider implements the SecretProvider interface using sops.
19 type SopsProvider struct {58 type SopsProvider struct {
20 timeout time.Duration59 timeout time.Duration
@@ -60,9 +99,12 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro
60 return "", fmt.Errorf("invalid key path: must contain only alphanumeric characters, dots, underscores, hyphens, and brackets")99 return "", fmt.Errorf("invalid key path: must contain only alphanumeric characters, dots, underscores, hyphens, and brackets")
61 }100 }
62 101
102+ // Convert dot notation to sops --extract Python dictionary syntax
103+ extractPath := convertToSopsExtractPath(keyPath)
104+
63 // Use sops -d --extract to get specific key105 // Use sops -d --extract to get specific key
64 // #nosec G204 - filePath and keyPath are validated above106 // #nosec G204 - filePath and keyPath are validated above
65- cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", fmt.Sprintf("[\"%s\"]", keyPath), cleanPath)107+ cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)
66 output, err := cmd.Output()108 output, err := cmd.Output()
67 if err != nil {109 if err != nil {
68 if exitError, ok := err.(*exec.ExitError); ok {110 if exitError, ok := err.(*exec.ExitError); ok {
A internal/adapters/out/secrets/sops_test.go +83 -0
@@ -0,0 +1,83 @@
1+package secrets
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestConvertToSopsExtractPath(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ expected string
14+ }{
15+ {
16+ name: "empty string",
17+ input: "",
18+ expected: "",
19+ },
20+ {
21+ name: "single key",
22+ input: "password",
23+ expected: `["password"]`,
24+ },
25+ {
26+ name: "two level nested",
27+ input: "app.secret",
28+ expected: `["app"]["secret"]`,
29+ },
30+ {
31+ name: "three level nested",
32+ input: "app.database.password",
33+ expected: `["app"]["database"]["password"]`,
34+ },
35+ {
36+ name: "key with underscores",
37+ input: "app.database_password",
38+ expected: `["app"]["database_password"]`,
39+ },
40+ {
41+ name: "key with hyphens",
42+ input: "app.api-key",
43+ expected: `["app"]["api-key"]`,
44+ },
45+ {
46+ name: "array index only",
47+ input: "[0]",
48+ expected: `[0]`,
49+ },
50+ {
51+ name: "key with array index",
52+ input: "items[0]",
53+ expected: `["items"][0]`,
54+ },
55+ {
56+ name: "nested key with array index",
57+ input: "app.items[0]",
58+ expected: `["app"]["items"][0]`,
59+ },
60+ {
61+ name: "nested key with array then key",
62+ input: "app.items[0].name",
63+ expected: `["app"]["items"][0]["name"]`,
64+ },
65+ {
66+ name: "multiple array indices",
67+ input: "matrix[0][1]",
68+ expected: `["matrix"][0][1]`,
69+ },
70+ {
71+ name: "deeply nested with array",
72+ input: "config.databases[0].credentials.password",
73+ expected: `["config"]["databases"][0]["credentials"]["password"]`,
74+ },
75+ }
76+
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ result := convertToSopsExtractPath(tt.input)
80+ assert.Equal(t, tt.expected, result)
81+ })
82+ }
83+}
@@ -0,0 +1,83 @@
1+package secrets
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestConvertToSopsExtractPath(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ expected string
14+ }{
15+ {
16+ name: "empty string",
17+ input: "",
18+ expected: "",
19+ },
20+ {
21+ name: "single key",
22+ input: "password",
23+ expected: `["password"]`,
24+ },
25+ {
26+ name: "two level nested",
27+ input: "app.secret",
28+ expected: `["app"]["secret"]`,
29+ },
30+ {
31+ name: "three level nested",
32+ input: "app.database.password",
33+ expected: `["app"]["database"]["password"]`,
34+ },
35+ {
36+ name: "key with underscores",
37+ input: "app.database_password",
38+ expected: `["app"]["database_password"]`,
39+ },
40+ {
41+ name: "key with hyphens",
42+ input: "app.api-key",
43+ expected: `["app"]["api-key"]`,
44+ },
45+ {
46+ name: "array index only",
47+ input: "[0]",
48+ expected: `[0]`,
49+ },
50+ {
51+ name: "key with array index",
52+ input: "items[0]",
53+ expected: `["items"][0]`,
54+ },
55+ {
56+ name: "nested key with array index",
57+ input: "app.items[0]",
58+ expected: `["app"]["items"][0]`,
59+ },
60+ {
61+ name: "nested key with array then key",
62+ input: "app.items[0].name",
63+ expected: `["app"]["items"][0]["name"]`,
64+ },
65+ {
66+ name: "multiple array indices",
67+ input: "matrix[0][1]",
68+ expected: `["matrix"][0][1]`,
69+ },
70+ {
71+ name: "deeply nested with array",
72+ input: "config.databases[0].credentials.password",
73+ expected: `["config"]["databases"][0]["credentials"]["password"]`,
74+ },
75+ }
76+
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ result := convertToSopsExtractPath(tt.input)
80+ assert.Equal(t, tt.expected, result)
81+ })
82+ }
83+}

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