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 keys := []string{}
67 if exists {
68 for _, line := range strings.Split(content, "\n") {
69 key := strings.TrimSpace(line)
70 if key == "" {
71 continue
72 }
73 keys = append(keys, key)
74 }
75 }
76 sort.Strings(keys)
77
78 // Recover keys that may exist in pass but are missing from the manifest.
79 discovered, err := s.listDomainKeys(domainName)
80 if err != nil {
81 return nil, err
82 }
83
84 merged, changed := mergeUniqueKeys(keys, discovered)
85 if changed {
86 // Best-effort self-heal of stale manifest.
87 writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout)
88 writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n"))
89 writeCancel()
90 if writeErr != nil {
91 s.log.Warn().
92 Str(zerowrap.FieldLayer, "adapter").
93 Str(zerowrap.FieldAdapter, "domainsecrets").
94 Str("domain", domainName).
95 Err(writeErr).
96 Msg("failed to self-heal pass manifest, continuing with recovered keys")
97 }
98 }
99
100 return merged, nil
101}
102
103// GetAll returns all secrets for a domain as a key-value map.
104func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
105 keys, err := s.ListKeys(domainName)
106 if err != nil {
107 return nil, err
108 }
109
110 secretsMap := make(map[string]string)
111 for _, key := range keys {
112 keyPath, err := s.keyPath(domainName, key)
113 if err != nil {
114 return nil, err
115 }
116
117 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
118 value, exists, err := s.passShow(ctx, keyPath)
119 cancel()
120 if err != nil {
121 return nil, err
122 }
123 if !exists {
124 s.log.Warn().
125 Str(zerowrap.FieldLayer, "adapter").
126 Str(zerowrap.FieldAdapter, "domainsecrets").
127 Str("domain", domainName).
128 Str("key", key).
129 Msg("secret listed in manifest but missing in pass")
130 continue
131 }
132 secretsMap[key] = value
133 }
134
135 return secretsMap, nil
136}
137
138// Set sets or updates multiple secrets for a domain.
139func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
140 if _, err := s.domainPath(domainName); err != nil {
141 return err
142 }
143
144 existingKeys, err := s.ListKeys(domainName)
145 if err != nil {
146 return err
147 }
148
149 existingSet := make(map[string]struct{}, len(existingKeys))
150 for _, key := range existingKeys {
151 existingSet[key] = struct{}{}
152 }
153
154 insertedNewPaths := make([]string, 0, len(secretsMap))
155 for key, value := range secretsMap {
156 if err := domain.ValidateEnvKey(key); err != nil {
157 return err
158 }
159
160 keyPath, err := s.keyPath(domainName, key)
161 if err != nil {
162 return err
163 }
164
165 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
166 err = s.passInsert(ctx, keyPath, value)
167 cancel()
168 if err != nil {
169 s.cleanupInsertedPaths(insertedNewPaths)
170 return fmt.Errorf("failed to store secret %s: %w", key, err)
171 }
172
173 if _, exists := existingSet[key]; !exists {
174 insertedNewPaths = append(insertedNewPaths, keyPath)
175 }
176 }
177
178 keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
179 for _, key := range existingKeys {
180 keySet[key] = struct{}{}
181 }
182 for key := range secretsMap {
183 keySet[key] = struct{}{}
184 }
185
186 keys := make([]string, 0, len(keySet))
187 for key := range keySet {
188 keys = append(keys, key)
189 }
190 sort.Strings(keys)
191
192 manifestPath, err := s.manifestPath(domainName)
193 if err != nil {
194 return err
195 }
196
197 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
198 if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
199 cancel()
200 s.cleanupInsertedPaths(insertedNewPaths)
201 return fmt.Errorf("failed to update manifest: %w", err)
202 }
203 cancel()
204
205 return nil
206}
207
208// Delete removes a specific secret key from a domain.
209func (s *PassStore) Delete(domainName, key string) error {
210 if _, err := s.domainPath(domainName); err != nil {
211 return err
212 }
213
214 keyPath, err := s.keyPath(domainName, key)
215 if err != nil {
216 return err
217 }
218
219 rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
220 defer rmCancel()
221 if err := s.passRemove(rmCtx, keyPath); err != nil {
222 return err
223 }
224
225 keys, err := s.ListKeys(domainName)
226 if err != nil {
227 return err
228 }
229
230 updated := make([]string, 0, len(keys))
231 for _, existingKey := range keys {
232 if existingKey == key {
233 continue
234 }
235 updated = append(updated, existingKey)
236 }
237 sort.Strings(updated)
238
239 manifestPath, err := s.manifestPath(domainName)
240 if err != nil {
241 return err
242 }
243
244 insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
245 defer insertCancel()
246 if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
247 return fmt.Errorf("failed to update manifest: %w", err)
248 }
249
250 return nil
251}
252
253// SetAttachment sets or updates multiple secrets for an attachment container.
254func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error {
255 if _, err := s.attachmentPath(containerName); err != nil {
256 return err
257 }
258
259 existingKeys, err := s.listAttachmentKeys(containerName)
260 if err != nil {
261 return err
262 }
263
264 existingSet := make(map[string]struct{}, len(existingKeys))
265 for _, key := range existingKeys {
266 existingSet[key] = struct{}{}
267 }
268
269 insertedNewPaths := make([]string, 0, len(secretsMap))
270 for key, value := range secretsMap {
271 if err := domain.ValidateEnvKey(key); err != nil {
272 return err
273 }
274
275 keyPath, err := s.attachmentKeyPath(containerName, key)
276 if err != nil {
277 return err
278 }
279
280 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
281 err = s.passInsert(ctx, keyPath, value)
282 cancel()
283 if err != nil {
284 s.cleanupInsertedPaths(insertedNewPaths)
285 return fmt.Errorf("failed to store attachment secret %s: %w", key, err)
286 }
287
288 if _, exists := existingSet[key]; !exists {
289 insertedNewPaths = append(insertedNewPaths, keyPath)
290 }
291 }
292
293 keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
294 for _, key := range existingKeys {
295 keySet[key] = struct{}{}
296 }
297 for key := range secretsMap {
298 keySet[key] = struct{}{}
299 }
300
301 keys := make([]string, 0, len(keySet))
302 for key := range keySet {
303 keys = append(keys, key)
304 }
305 sort.Strings(keys)
306
307 manifestPath, err := s.attachmentManifestPath(containerName)
308 if err != nil {
309 return err
310 }
311
312 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
313 if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
314 cancel()
315 s.cleanupInsertedPaths(insertedNewPaths)
316 return fmt.Errorf("failed to update attachment manifest: %w", err)
317 }
318 cancel()
319
320 return nil
321}
322
323// DeleteAttachment removes a specific secret key from an attachment container.
324func (s *PassStore) DeleteAttachment(containerName, key string) error {
325 if _, err := s.attachmentPath(containerName); err != nil {
326 return err
327 }
328
329 keyPath, err := s.attachmentKeyPath(containerName, key)
330 if err != nil {
331 return err
332 }
333
334 rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
335 defer rmCancel()
336 if err := s.passRemove(rmCtx, keyPath); err != nil {
337 return err
338 }
339
340 keys, err := s.listAttachmentKeys(containerName)
341 if err != nil {
342 return err
343 }
344
345 updated := make([]string, 0, len(keys))
346 for _, existingKey := range keys {
347 if existingKey == key {
348 continue
349 }
350 updated = append(updated, existingKey)
351 }
352 sort.Strings(updated)
353
354 manifestPath, err := s.attachmentManifestPath(containerName)
355 if err != nil {
356 return err
357 }
358
359 insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
360 defer insertCancel()
361 if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
362 return fmt.Errorf("failed to update attachment manifest: %w", err)
363 }
364
365 return nil
366}
367
368// GetAllAttachment returns all secrets for an attachment container as a key-value map.
369func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {
370 keys, err := s.listAttachmentKeys(containerName)
371 if err != nil {
372 return nil, err
373 }
374
375 secretsMap := make(map[string]string)
376 for _, key := range keys {
377 keyPath, err := s.attachmentKeyPath(containerName, key)
378 if err != nil {
379 return nil, err
380 }
381
382 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
383 value, exists, err := s.passShow(ctx, keyPath)
384 cancel()
385 if err != nil {
386 return nil, err
387 }
388 if !exists {
389 s.log.Warn().
390 Str(zerowrap.FieldLayer, "adapter").
391 Str(zerowrap.FieldAdapter, "domainsecrets").
392 Str("container", containerName).
393 Str("key", key).
394 Msg("attachment secret listed in manifest but missing in pass")
395 continue
396 }
397 secretsMap[key] = value
398 }
399
400 return secretsMap, nil
401}
402
403// ListAttachmentKeys finds attachment secrets for a domain from pass.
404// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
405func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
406 if _, err := s.domainPath(domainName); err != nil {
407 return nil, err
408 }
409
410 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
411 defer cancel()
412
413 containers, err := s.listTopLevelEntries(ctx, PassAttachmentPath)
414 if err != nil {
415 return nil, err
416 }
417
418 // Try both new and legacy sanitization for backwards compatibility
419 sanitizedDomain := domain.SanitizeDomainForContainer(domainName)
420 sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
421 prefixes := []string{
422 "gordon-" + sanitizedDomain + "-", // New format (collision-resistant)
423 "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible)
424 }
425
426 seen := make(map[string]bool)
427 var results []out.AttachmentSecrets
428 for _, containerName := range containers {
429 matches := false
430 for _, prefix := range prefixes {
431 if strings.HasPrefix(containerName, prefix) {
432 matches = true
433 break
434 }
435 }
436 if !matches {
437 continue
438 }
439
440 // Deduplicate results
441 if seen[containerName] {
442 continue
443 }
444 seen[containerName] = true
445
446 manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName)
447 if err := secrets.ValidatePath(manifestPath); err != nil {
448 return nil, err
449 }
450
451 showCtx, showCancel := context.WithTimeout(context.Background(), s.timeout)
452 content, exists, err := s.passShow(showCtx, manifestPath)
453 showCancel()
454 if err != nil {
455 return nil, err
456 }
457 if !exists {
458 continue
459 }
460
461 keys := []string{}
462 for _, line := range strings.Split(content, "\n") {
463 key := strings.TrimSpace(line)
464 if key == "" {
465 continue
466 }
467 keys = append(keys, key)
468 }
469
470 if len(keys) > 0 {
471 results = append(results, out.AttachmentSecrets{
472 Service: containerName,
473 Keys: keys,
474 })
475 }
476 }
477
478 return results, nil
479}
480
481func (s *PassStore) domainPath(domainName string) (string, error) {
482 safeDomain, err := s.sanitizeDomain(domainName)
483 if err != nil {
484 return "", err
485 }
486 path := fmt.Sprintf("%s/%s", PassDomainSecretsPath, safeDomain)
487 if err := secrets.ValidatePath(path); err != nil {
488 return "", err
489 }
490 return path, nil
491}
492
493func (s *PassStore) keyPath(domainName, key string) (string, error) {
494 domainPath, err := s.domainPath(domainName)
495 if err != nil {
496 return "", err
497 }
498 if err := domain.ValidateEnvKey(key); err != nil {
499 return "", err
500 }
501 path := fmt.Sprintf("%s/%s", domainPath, key)
502 if err := secrets.ValidatePath(path); err != nil {
503 return "", err
504 }
505 return path, nil
506}
507
508func (s *PassStore) manifestPath(domainName string) (string, error) {
509 domainPath, err := s.domainPath(domainName)
510 if err != nil {
511 return "", err
512 }
513 path := fmt.Sprintf("%s/.keys", domainPath)
514 if err := secrets.ValidatePath(path); err != nil {
515 return "", err
516 }
517 return path, nil
518}
519
520func (s *PassStore) listDomainKeys(domainName string) ([]string, error) {
521 basePath, err := s.domainPath(domainName)
522 if err != nil {
523 return nil, err
524 }
525
526 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
527 defer cancel()
528
529 entries, err := s.listTopLevelEntries(ctx, basePath)
530 if err != nil {
531 return nil, err
532 }
533
534 keys := make([]string, 0, len(entries))
535 for _, entry := range entries {
536 if entry == ".keys" {
537 continue
538 }
539 if err := domain.ValidateEnvKey(entry); err != nil {
540 continue
541 }
542 keys = append(keys, entry)
543 }
544
545 sort.Strings(keys)
546 return keys, nil
547}
548
549func (s *PassStore) attachmentPath(containerName string) (string, error) {
550 if err := domain.ValidateContainerName(containerName); err != nil {
551 return "", err
552 }
553 path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName)
554 if err := secrets.ValidatePath(path); err != nil {
555 return "", err
556 }
557 return path, nil
558}
559
560func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) {
561 attachmentPath, err := s.attachmentPath(containerName)
562 if err != nil {
563 return "", err
564 }
565 if err := domain.ValidateEnvKey(key); err != nil {
566 return "", err
567 }
568 path := fmt.Sprintf("%s/%s", attachmentPath, key)
569 if err := secrets.ValidatePath(path); err != nil {
570 return "", err
571 }
572 return path, nil
573}
574
575func (s *PassStore) attachmentManifestPath(containerName string) (string, error) {
576 attachmentPath, err := s.attachmentPath(containerName)
577 if err != nil {
578 return "", err
579 }
580 path := fmt.Sprintf("%s/.keys", attachmentPath)
581 if err := secrets.ValidatePath(path); err != nil {
582 return "", err
583 }
584 return path, nil
585}
586
587func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) {
588 manifestPath, err := s.attachmentManifestPath(containerName)
589 if err != nil {
590 return nil, err
591 }
592
593 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
594 defer cancel()
595
596 content, exists, err := s.passShow(ctx, manifestPath)
597 if err != nil {
598 return nil, err
599 }
600 if !exists {
601 return []string{}, nil
602 }
603
604 keys := []string{}
605 for _, line := range strings.Split(content, "\n") {
606 key := strings.TrimSpace(line)
607 if key == "" {
608 continue
609 }
610 keys = append(keys, key)
611 }
612
613 return keys, nil
614}
615
616func (s *PassStore) sanitizeDomain(domainName string) (string, error) {
617 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
618 if err != nil {
619 s.log.Warn().
620 Str(zerowrap.FieldLayer, "adapter").
621 Str(zerowrap.FieldAdapter, "domainsecrets").
622 Str("domain", domainName).
623 Err(err).
624 Msg("rejected invalid domain")
625 return "", domain.ErrPathTraversal
626 }
627 return safeDomain, nil
628}
629
630// ManifestExists checks if the manifest exists for a domain.
631func (s *PassStore) ManifestExists(domainName string) (bool, error) {
632 manifestPath, err := s.manifestPath(domainName)
633 if err != nil {
634 return false, err
635 }
636
637 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
638 defer cancel()
639
640 _, exists, err := s.passShow(ctx, manifestPath)
641 if err != nil {
642 return false, err
643 }
644
645 return exists, nil
646}
647
648func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
649 cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
650 cmd.Stdin = strings.NewReader(value)
651 output, err := cmd.CombinedOutput()
652 if err != nil {
653 return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err)
654 }
655 return nil
656}
657
658func (s *PassStore) passRemove(ctx context.Context, path string) error {
659 cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path)
660 output, err := cmd.CombinedOutput()
661 if err != nil {
662 if passEntryMissing(string(output)) {
663 return nil
664 }
665 return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
666 }
667 return nil
668}
669
670func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) {
671 cmd := exec.CommandContext(ctx, "pass", "show", path)
672 output, err := cmd.CombinedOutput()
673 if err != nil {
674 if passEntryMissing(string(output)) {
675 return "", false, nil
676 }
677 return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)
678 }
679
680 clean := ansiRegex.ReplaceAllString(string(output), "")
681 clean = strings.TrimRight(clean, "\r\n")
682 return clean, true, nil
683}
684
685func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
686 if err := secrets.ValidatePath(basePath); err != nil {
687 return nil, err
688 }
689
690 cmd := exec.CommandContext(ctx, "pass", "ls", basePath)
691 output, err := cmd.CombinedOutput()
692 if err != nil {
693 if passEntryMissing(string(output)) {
694 return []string{}, nil
695 }
696 return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err)
697 }
698
699 entries := []string{}
700 for _, entry := range parsePassListOutput(basePath, string(output)) {
701 if entry.depth == 1 {
702 entries = append(entries, entry.name)
703 }
704 }
705
706 return entries, nil
707}
708
709type passListEntry struct {
710 name string
711 depth int
712}
713
714func parsePassListOutput(basePath, output string) []passListEntry {
715 lines := strings.Split(output, "\n")
716 entries := []passListEntry{}
717
718 for _, line := range lines {
719 line = ansiRegex.ReplaceAllString(line, "")
720 if strings.TrimSpace(line) == "" {
721 continue
722 }
723
724 depth := 0
725 prefixLoop:
726 for {
727 switch {
728 case strings.HasPrefix(line, "│ "):
729 line = strings.TrimPrefix(line, "│ ")
730 depth++
731 case strings.HasPrefix(line, "| "):
732 line = strings.TrimPrefix(line, "| ")
733 depth++
734 case strings.HasPrefix(line, " "):
735 line = strings.TrimPrefix(line, " ")
736 depth++
737 default:
738 break prefixLoop
739 }
740 }
741
742 switch {
743 case strings.HasPrefix(line, "├── "):
744 line = strings.TrimPrefix(line, "├── ")
745 depth++
746 case strings.HasPrefix(line, "└── "):
747 line = strings.TrimPrefix(line, "└── ")
748 depth++
749 case strings.HasPrefix(line, "|-- "):
750 line = strings.TrimPrefix(line, "|-- ")
751 depth++
752 case strings.HasPrefix(line, "`-- "):
753 line = strings.TrimPrefix(line, "`-- ")
754 depth++
755 }
756
757 name := strings.TrimSpace(line)
758 if name == "" || name == basePath {
759 continue
760 }
761 if depth == 0 {
762 continue
763 }
764
765 entries = append(entries, passListEntry{name: name, depth: depth})
766 }
767
768 return entries
769}
770
771func passEntryMissing(output string) bool {
772 clean := ansiRegex.ReplaceAllString(output, "")
773 lower := strings.ToLower(clean)
774 return strings.Contains(lower, "not in the password store") ||
775 strings.Contains(lower, "password store is empty")
776}
777
778func mergeUniqueKeys(primary, secondary []string) ([]string, bool) {
779 mergedSet := make(map[string]struct{}, len(primary)+len(secondary))
780 for _, key := range primary {
781 mergedSet[key] = struct{}{}
782 }
783 for _, key := range secondary {
784 mergedSet[key] = struct{}{}
785 }
786
787 merged := make([]string, 0, len(mergedSet))
788 for key := range mergedSet {
789 merged = append(merged, key)
790 }
791 sort.Strings(merged)
792
793 if len(merged) != len(primary) {
794 return merged, true
795 }
796
797 for i := range primary {
798 if primary[i] != merged[i] {
799 return merged, true
800 }
801 }
802
803 return merged, false
804}
805
806func (s *PassStore) cleanupInsertedPaths(paths []string) {
807 if len(paths) == 0 {
808 return
809 }
810
811 for _, path := range paths {
812 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
813 _ = s.passRemove(ctx, path)
814 cancel()
815 }
816}