1// Package domainsecrets implements the DomainSecretStore adapter using pass.
2package domainsecrets
3
4import (
5 "context"
6 "fmt"
7 "os/exec"
8 "regexp"
9 "sort"
10 "strings"
11 "time"
12
13 "github.com/bnema/zerowrap"
14
15 "github.com/bnema/gordon/internal/adapters/out/secrets"
16 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20// ansiRegex matches ANSI escape sequences for stripping from pass output.
21var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
22
23const (
24 PassDomainSecretsPath = "gordon/env" //nolint:gosec // Not a credential, this is a pass store path.
25 PassAttachmentPath = "gordon/env/attachments" //nolint:gosec // Not a credential, this is a pass store path.
26)
27
28// PassStore implements the DomainSecretStore interface using the pass password manager.
29type PassStore struct {
30 timeout time.Duration
31 log zerowrap.Logger
32}
33
34// NewPassStore creates a new pass-based domain secret store.
35func NewPassStore(log zerowrap.Logger) (*PassStore, error) {
36 if err := exec.Command("pass", "version").Run(); err != nil {
37 return nil, fmt.Errorf("pass is not available: %w", err)
38 }
39
40 log.Debug().
41 Str(zerowrap.FieldLayer, "adapter").
42 Str(zerowrap.FieldAdapter, "domainsecrets").
43 Str("provider", "pass").
44 Msg("domain secret store initialized")
45
46 return &PassStore{
47 timeout: 10 * time.Second,
48 log: log,
49 }, nil
50}
51
52// ListKeys returns the list of secret keys for a domain (not values).
53func (s *PassStore) ListKeys(domainName string) ([]string, error) {
54 manifestPath, err := s.manifestPath(domainName)
55 if err != nil {
56 return nil, err
57 }
58
59 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
60 defer cancel()
61
62 content, exists, err := s.passShow(ctx, manifestPath)
63 if err != nil {
64 return nil, err
65 }
66 if !exists {
67 return []string{}, nil
68 }
69
70 keys := []string{}
71 for _, line := range strings.Split(content, "\n") {
72 key := strings.TrimSpace(line)
73 if key == "" {
74 continue
75 }
76 keys = append(keys, key)
77 }
78
79 return keys, nil
80}
81
82// GetAll returns all secrets for a domain as a key-value map.
83func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
84 keys, err := s.ListKeys(domainName)
85 if err != nil {
86 return nil, err
87 }
88
89 secretsMap := make(map[string]string)
90 for _, key := range keys {
91 keyPath, err := s.keyPath(domainName, key)
92 if err != nil {
93 return nil, err
94 }
95
96 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
97 value, exists, err := s.passShow(ctx, keyPath)
98 cancel()
99 if err != nil {
100 return nil, err
101 }
102 if !exists {
103 s.log.Warn().
104 Str(zerowrap.FieldLayer, "adapter").
105 Str(zerowrap.FieldAdapter, "domainsecrets").
106 Str("domain", domainName).
107 Str("key", key).
108 Msg("secret listed in manifest but missing in pass")
109 continue
110 }
111 secretsMap[key] = value
112 }
113
114 return secretsMap, nil
115}
116
117// Set sets or updates multiple secrets for a domain.
118func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
119 if _, err := s.domainPath(domainName); err != nil {
120 return err
121 }
122
123 existingKeys, err := s.ListKeys(domainName)
124 if err != nil {
125 return err
126 }
127
128 existingSet := make(map[string]struct{}, len(existingKeys))
129 for _, key := range existingKeys {
130 existingSet[key] = struct{}{}
131 }
132
133 insertedNewPaths := make([]string, 0, len(secretsMap))
134 for key, value := range secretsMap {
135 if err := domain.ValidateEnvKey(key); err != nil {
136 return err
137 }
138
139 keyPath, err := s.keyPath(domainName, key)
140 if err != nil {
141 return err
142 }
143
144 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
145 err = s.passInsert(ctx, keyPath, value)
146 cancel()
147 if err != nil {
148 s.cleanupInsertedPaths(insertedNewPaths)
149 return fmt.Errorf("failed to store secret %s: %w", key, err)
150 }
151
152 if _, exists := existingSet[key]; !exists {
153 insertedNewPaths = append(insertedNewPaths, keyPath)
154 }
155 }
156
157 keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
158 for _, key := range existingKeys {
159 keySet[key] = struct{}{}
160 }
161 for key := range secretsMap {
162 keySet[key] = struct{}{}
163 }
164
165 keys := make([]string, 0, len(keySet))
166 for key := range keySet {
167 keys = append(keys, key)
168 }
169 sort.Strings(keys)
170
171 manifestPath, err := s.manifestPath(domainName)
172 if err != nil {
173 return err
174 }
175
176 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
177 if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
178 cancel()
179 s.cleanupInsertedPaths(insertedNewPaths)
180 return fmt.Errorf("failed to update manifest: %w", err)
181 }
182 cancel()
183
184 return nil
185}
186
187// Delete removes a specific secret key from a domain.
188func (s *PassStore) Delete(domainName, key string) error {
189 if _, err := s.domainPath(domainName); err != nil {
190 return err
191 }
192
193 keyPath, err := s.keyPath(domainName, key)
194 if err != nil {
195 return err
196 }
197
198 rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
199 defer rmCancel()
200 if err := s.passRemove(rmCtx, keyPath); err != nil {
201 return err
202 }
203
204 keys, err := s.ListKeys(domainName)
205 if err != nil {
206 return err
207 }
208
209 updated := make([]string, 0, len(keys))
210 for _, existingKey := range keys {
211 if existingKey == key {
212 continue
213 }
214 updated = append(updated, existingKey)
215 }
216 sort.Strings(updated)
217
218 manifestPath, err := s.manifestPath(domainName)
219 if err != nil {
220 return err
221 }
222
223 insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
224 defer insertCancel()
225 if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
226 return fmt.Errorf("failed to update manifest: %w", err)
227 }
228
229 return nil
230}
231
232// SetAttachment sets or updates multiple secrets for an attachment container.
233func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error {
234 if _, err := s.attachmentPath(containerName); err != nil {
235 return err
236 }
237
238 existingKeys, err := s.listAttachmentKeys(containerName)
239 if err != nil {
240 return err
241 }
242
243 existingSet := make(map[string]struct{}, len(existingKeys))
244 for _, key := range existingKeys {
245 existingSet[key] = struct{}{}
246 }
247
248 insertedNewPaths := make([]string, 0, len(secretsMap))
249 for key, value := range secretsMap {
250 if err := domain.ValidateEnvKey(key); err != nil {
251 return err
252 }
253
254 keyPath, err := s.attachmentKeyPath(containerName, key)
255 if err != nil {
256 return err
257 }
258
259 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
260 err = s.passInsert(ctx, keyPath, value)
261 cancel()
262 if err != nil {
263 s.cleanupInsertedPaths(insertedNewPaths)
264 return fmt.Errorf("failed to store attachment secret %s: %w", key, err)
265 }
266
267 if _, exists := existingSet[key]; !exists {
268 insertedNewPaths = append(insertedNewPaths, keyPath)
269 }
270 }
271
272 keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
273 for _, key := range existingKeys {
274 keySet[key] = struct{}{}
275 }
276 for key := range secretsMap {
277 keySet[key] = struct{}{}
278 }
279
280 keys := make([]string, 0, len(keySet))
281 for key := range keySet {
282 keys = append(keys, key)
283 }
284 sort.Strings(keys)
285
286 manifestPath, err := s.attachmentManifestPath(containerName)
287 if err != nil {
288 return err
289 }
290
291 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
292 if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
293 cancel()
294 s.cleanupInsertedPaths(insertedNewPaths)
295 return fmt.Errorf("failed to update attachment manifest: %w", err)
296 }
297 cancel()
298
299 return nil
300}
301
302// GetAllAttachment returns all secrets for an attachment container as a key-value map.
303func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {
304 keys, err := s.listAttachmentKeys(containerName)
305 if err != nil {
306 return nil, err
307 }
308
309 secretsMap := make(map[string]string)
310 for _, key := range keys {
311 keyPath, err := s.attachmentKeyPath(containerName, key)
312 if err != nil {
313 return nil, err
314 }
315
316 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
317 value, exists, err := s.passShow(ctx, keyPath)
318 cancel()
319 if err != nil {
320 return nil, err
321 }
322 if !exists {
323 s.log.Warn().
324 Str(zerowrap.FieldLayer, "adapter").
325 Str(zerowrap.FieldAdapter, "domainsecrets").
326 Str("container", containerName).
327 Str("key", key).
328 Msg("attachment secret listed in manifest but missing in pass")
329 continue
330 }
331 secretsMap[key] = value
332 }
333
334 return secretsMap, nil
335}
336
337// ListAttachmentKeys finds attachment secrets for a domain from pass.
338// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
339func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
340 if _, err := s.domainPath(domainName); err != nil {
341 return nil, err
342 }
343
344 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
345 defer cancel()
346
347 containers, err := s.listTopLevelEntries(ctx, PassAttachmentPath)
348 if err != nil {
349 return nil, err
350 }
351
352 // Try both new and legacy sanitization for backwards compatibility
353 sanitizedDomain := domain.SanitizeDomainForContainer(domainName)
354 sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
355 prefixes := []string{
356 "gordon-" + sanitizedDomain + "-", // New format (collision-resistant)
357 "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible)
358 }
359
360 seen := make(map[string]bool)
361 var results []out.AttachmentSecrets
362 for _, containerName := range containers {
363 matches := false
364 for _, prefix := range prefixes {
365 if strings.HasPrefix(containerName, prefix) {
366 matches = true
367 break
368 }
369 }
370 if !matches {
371 continue
372 }
373
374 // Deduplicate results
375 if seen[containerName] {
376 continue
377 }
378 seen[containerName] = true
379
380 manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName)
381 if err := secrets.ValidatePath(manifestPath); err != nil {
382 return nil, err
383 }
384
385 showCtx, showCancel := context.WithTimeout(context.Background(), s.timeout)
386 content, exists, err := s.passShow(showCtx, manifestPath)
387 showCancel()
388 if err != nil {
389 return nil, err
390 }
391 if !exists {
392 continue
393 }
394
395 keys := []string{}
396 for _, line := range strings.Split(content, "\n") {
397 key := strings.TrimSpace(line)
398 if key == "" {
399 continue
400 }
401 keys = append(keys, key)
402 }
403
404 if len(keys) > 0 {
405 results = append(results, out.AttachmentSecrets{
406 Service: containerName,
407 Keys: keys,
408 })
409 }
410 }
411
412 return results, nil
413}
414
415func (s *PassStore) domainPath(domainName string) (string, error) {
416 safeDomain, err := s.sanitizeDomain(domainName)
417 if err != nil {
418 return "", err
419 }
420 path := fmt.Sprintf("%s/%s", PassDomainSecretsPath, safeDomain)
421 if err := secrets.ValidatePath(path); err != nil {
422 return "", err
423 }
424 return path, nil
425}
426
427func (s *PassStore) keyPath(domainName, key string) (string, error) {
428 domainPath, err := s.domainPath(domainName)
429 if err != nil {
430 return "", err
431 }
432 if err := domain.ValidateEnvKey(key); err != nil {
433 return "", err
434 }
435 path := fmt.Sprintf("%s/%s", domainPath, key)
436 if err := secrets.ValidatePath(path); err != nil {
437 return "", err
438 }
439 return path, nil
440}
441
442func (s *PassStore) manifestPath(domainName string) (string, error) {
443 domainPath, err := s.domainPath(domainName)
444 if err != nil {
445 return "", err
446 }
447 path := fmt.Sprintf("%s/.keys", domainPath)
448 if err := secrets.ValidatePath(path); err != nil {
449 return "", err
450 }
451 return path, nil
452}
453
454func (s *PassStore) attachmentPath(containerName string) (string, error) {
455 if err := domain.ValidateContainerName(containerName); err != nil {
456 return "", err
457 }
458 path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName)
459 if err := secrets.ValidatePath(path); err != nil {
460 return "", err
461 }
462 return path, nil
463}
464
465func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) {
466 attachmentPath, err := s.attachmentPath(containerName)
467 if err != nil {
468 return "", err
469 }
470 if err := domain.ValidateEnvKey(key); err != nil {
471 return "", err
472 }
473 path := fmt.Sprintf("%s/%s", attachmentPath, key)
474 if err := secrets.ValidatePath(path); err != nil {
475 return "", err
476 }
477 return path, nil
478}
479
480func (s *PassStore) attachmentManifestPath(containerName string) (string, error) {
481 attachmentPath, err := s.attachmentPath(containerName)
482 if err != nil {
483 return "", err
484 }
485 path := fmt.Sprintf("%s/.keys", attachmentPath)
486 if err := secrets.ValidatePath(path); err != nil {
487 return "", err
488 }
489 return path, nil
490}
491
492func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) {
493 manifestPath, err := s.attachmentManifestPath(containerName)
494 if err != nil {
495 return nil, err
496 }
497
498 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
499 defer cancel()
500
501 content, exists, err := s.passShow(ctx, manifestPath)
502 if err != nil {
503 return nil, err
504 }
505 if !exists {
506 return []string{}, nil
507 }
508
509 keys := []string{}
510 for _, line := range strings.Split(content, "\n") {
511 key := strings.TrimSpace(line)
512 if key == "" {
513 continue
514 }
515 keys = append(keys, key)
516 }
517
518 return keys, nil
519}
520
521func (s *PassStore) sanitizeDomain(domainName string) (string, error) {
522 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
523 if err != nil {
524 s.log.Warn().
525 Str(zerowrap.FieldLayer, "adapter").
526 Str(zerowrap.FieldAdapter, "domainsecrets").
527 Str("domain", domainName).
528 Err(err).
529 Msg("rejected invalid domain")
530 return "", domain.ErrPathTraversal
531 }
532 return safeDomain, nil
533}
534
535// ManifestExists checks if the manifest exists for a domain.
536func (s *PassStore) ManifestExists(domainName string) (bool, error) {
537 manifestPath, err := s.manifestPath(domainName)
538 if err != nil {
539 return false, err
540 }
541
542 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
543 defer cancel()
544
545 _, exists, err := s.passShow(ctx, manifestPath)
546 if err != nil {
547 return false, err
548 }
549
550 return exists, nil
551}
552
553func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
554 cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
555 cmd.Stdin = strings.NewReader(value)
556 output, err := cmd.CombinedOutput()
557 if err != nil {
558 return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err)
559 }
560 return nil
561}
562
563func (s *PassStore) passRemove(ctx context.Context, path string) error {
564 cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path)
565 output, err := cmd.CombinedOutput()
566 if err != nil {
567 if passEntryMissing(string(output)) {
568 return nil
569 }
570 return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
571 }
572 return nil
573}
574
575func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) {
576 cmd := exec.CommandContext(ctx, "pass", "show", path)
577 output, err := cmd.CombinedOutput()
578 if err != nil {
579 if passEntryMissing(string(output)) {
580 return "", false, nil
581 }
582 return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)
583 }
584
585 clean := ansiRegex.ReplaceAllString(string(output), "")
586 clean = strings.TrimRight(clean, "\r\n")
587 return clean, true, nil
588}
589
590func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
591 if err := secrets.ValidatePath(basePath); err != nil {
592 return nil, err
593 }
594
595 cmd := exec.CommandContext(ctx, "pass", "ls", basePath)
596 output, err := cmd.CombinedOutput()
597 if err != nil {
598 if passEntryMissing(string(output)) {
599 return []string{}, nil
600 }
601 return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err)
602 }
603
604 entries := []string{}
605 for _, entry := range parsePassListOutput(basePath, string(output)) {
606 if entry.depth == 1 {
607 entries = append(entries, entry.name)
608 }
609 }
610
611 return entries, nil
612}
613
614type passListEntry struct {
615 name string
616 depth int
617}
618
619func parsePassListOutput(basePath, output string) []passListEntry {
620 lines := strings.Split(output, "\n")
621 entries := []passListEntry{}
622
623 for _, line := range lines {
624 line = ansiRegex.ReplaceAllString(line, "")
625 if strings.TrimSpace(line) == "" {
626 continue
627 }
628
629 depth := 0
630 prefixLoop:
631 for {
632 switch {
633 case strings.HasPrefix(line, "│ "):
634 line = strings.TrimPrefix(line, "│ ")
635 depth++
636 case strings.HasPrefix(line, "| "):
637 line = strings.TrimPrefix(line, "| ")
638 depth++
639 case strings.HasPrefix(line, " "):
640 line = strings.TrimPrefix(line, " ")
641 depth++
642 default:
643 break prefixLoop
644 }
645 }
646
647 switch {
648 case strings.HasPrefix(line, "├── "):
649 line = strings.TrimPrefix(line, "├── ")
650 depth++
651 case strings.HasPrefix(line, "└── "):
652 line = strings.TrimPrefix(line, "└── ")
653 depth++
654 case strings.HasPrefix(line, "|-- "):
655 line = strings.TrimPrefix(line, "|-- ")
656 depth++
657 case strings.HasPrefix(line, "`-- "):
658 line = strings.TrimPrefix(line, "`-- ")
659 depth++
660 }
661
662 name := strings.TrimSpace(line)
663 if name == "" || name == basePath {
664 continue
665 }
666 if depth == 0 {
667 continue
668 }
669
670 entries = append(entries, passListEntry{name: name, depth: depth})
671 }
672
673 return entries
674}
675
676func passEntryMissing(output string) bool {
677 clean := ansiRegex.ReplaceAllString(output, "")
678 lower := strings.ToLower(clean)
679 return strings.Contains(lower, "not in the password store") ||
680 strings.Contains(lower, "password store is empty")
681}
682
683func (s *PassStore) cleanupInsertedPaths(paths []string) {
684 if len(paths) == 0 {
685 return
686 }
687
688 for _, path := range paths {
689 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
690 _ = s.passRemove(ctx, path)
691 cancel()
692 }
693}