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// DeleteAttachment removes a specific secret key from an attachment container.
303func (s *PassStore) DeleteAttachment(containerName, key string) error {
304 if _, err := s.attachmentPath(containerName); err != nil {
305 return err
306 }
307
308 keyPath, err := s.attachmentKeyPath(containerName, key)
309 if err != nil {
310 return err
311 }
312
313 rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
314 defer rmCancel()
315 if err := s.passRemove(rmCtx, keyPath); err != nil {
316 return err
317 }
318
319 keys, err := s.listAttachmentKeys(containerName)
320 if err != nil {
321 return err
322 }
323
324 updated := make([]string, 0, len(keys))
325 for _, existingKey := range keys {
326 if existingKey == key {
327 continue
328 }
329 updated = append(updated, existingKey)
330 }
331 sort.Strings(updated)
332
333 manifestPath, err := s.attachmentManifestPath(containerName)
334 if err != nil {
335 return err
336 }
337
338 insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
339 defer insertCancel()
340 if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
341 return fmt.Errorf("failed to update attachment manifest: %w", err)
342 }
343
344 return nil
345}
346
347// GetAllAttachment returns all secrets for an attachment container as a key-value map.
348func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {
349 keys, err := s.listAttachmentKeys(containerName)
350 if err != nil {
351 return nil, err
352 }
353
354 secretsMap := make(map[string]string)
355 for _, key := range keys {
356 keyPath, err := s.attachmentKeyPath(containerName, key)
357 if err != nil {
358 return nil, err
359 }
360
361 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
362 value, exists, err := s.passShow(ctx, keyPath)
363 cancel()
364 if err != nil {
365 return nil, err
366 }
367 if !exists {
368 s.log.Warn().
369 Str(zerowrap.FieldLayer, "adapter").
370 Str(zerowrap.FieldAdapter, "domainsecrets").
371 Str("container", containerName).
372 Str("key", key).
373 Msg("attachment secret listed in manifest but missing in pass")
374 continue
375 }
376 secretsMap[key] = value
377 }
378
379 return secretsMap, nil
380}
381
382// ListAttachmentKeys finds attachment secrets for a domain from pass.
383// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
384func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
385 if _, err := s.domainPath(domainName); err != nil {
386 return nil, err
387 }
388
389 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
390 defer cancel()
391
392 containers, err := s.listTopLevelEntries(ctx, PassAttachmentPath)
393 if err != nil {
394 return nil, err
395 }
396
397 // Try both new and legacy sanitization for backwards compatibility
398 sanitizedDomain := domain.SanitizeDomainForContainer(domainName)
399 sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
400 prefixes := []string{
401 "gordon-" + sanitizedDomain + "-", // New format (collision-resistant)
402 "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible)
403 }
404
405 seen := make(map[string]bool)
406 var results []out.AttachmentSecrets
407 for _, containerName := range containers {
408 matches := false
409 for _, prefix := range prefixes {
410 if strings.HasPrefix(containerName, prefix) {
411 matches = true
412 break
413 }
414 }
415 if !matches {
416 continue
417 }
418
419 // Deduplicate results
420 if seen[containerName] {
421 continue
422 }
423 seen[containerName] = true
424
425 manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName)
426 if err := secrets.ValidatePath(manifestPath); err != nil {
427 return nil, err
428 }
429
430 showCtx, showCancel := context.WithTimeout(context.Background(), s.timeout)
431 content, exists, err := s.passShow(showCtx, manifestPath)
432 showCancel()
433 if err != nil {
434 return nil, err
435 }
436 if !exists {
437 continue
438 }
439
440 keys := []string{}
441 for _, line := range strings.Split(content, "\n") {
442 key := strings.TrimSpace(line)
443 if key == "" {
444 continue
445 }
446 keys = append(keys, key)
447 }
448
449 if len(keys) > 0 {
450 results = append(results, out.AttachmentSecrets{
451 Service: containerName,
452 Keys: keys,
453 })
454 }
455 }
456
457 return results, nil
458}
459
460func (s *PassStore) domainPath(domainName string) (string, error) {
461 safeDomain, err := s.sanitizeDomain(domainName)
462 if err != nil {
463 return "", err
464 }
465 path := fmt.Sprintf("%s/%s", PassDomainSecretsPath, safeDomain)
466 if err := secrets.ValidatePath(path); err != nil {
467 return "", err
468 }
469 return path, nil
470}
471
472func (s *PassStore) keyPath(domainName, key string) (string, error) {
473 domainPath, err := s.domainPath(domainName)
474 if err != nil {
475 return "", err
476 }
477 if err := domain.ValidateEnvKey(key); err != nil {
478 return "", err
479 }
480 path := fmt.Sprintf("%s/%s", domainPath, key)
481 if err := secrets.ValidatePath(path); err != nil {
482 return "", err
483 }
484 return path, nil
485}
486
487func (s *PassStore) manifestPath(domainName string) (string, error) {
488 domainPath, err := s.domainPath(domainName)
489 if err != nil {
490 return "", err
491 }
492 path := fmt.Sprintf("%s/.keys", domainPath)
493 if err := secrets.ValidatePath(path); err != nil {
494 return "", err
495 }
496 return path, nil
497}
498
499func (s *PassStore) attachmentPath(containerName string) (string, error) {
500 if err := domain.ValidateContainerName(containerName); err != nil {
501 return "", err
502 }
503 path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName)
504 if err := secrets.ValidatePath(path); err != nil {
505 return "", err
506 }
507 return path, nil
508}
509
510func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) {
511 attachmentPath, err := s.attachmentPath(containerName)
512 if err != nil {
513 return "", err
514 }
515 if err := domain.ValidateEnvKey(key); err != nil {
516 return "", err
517 }
518 path := fmt.Sprintf("%s/%s", attachmentPath, key)
519 if err := secrets.ValidatePath(path); err != nil {
520 return "", err
521 }
522 return path, nil
523}
524
525func (s *PassStore) attachmentManifestPath(containerName string) (string, error) {
526 attachmentPath, err := s.attachmentPath(containerName)
527 if err != nil {
528 return "", err
529 }
530 path := fmt.Sprintf("%s/.keys", attachmentPath)
531 if err := secrets.ValidatePath(path); err != nil {
532 return "", err
533 }
534 return path, nil
535}
536
537func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) {
538 manifestPath, err := s.attachmentManifestPath(containerName)
539 if err != nil {
540 return nil, err
541 }
542
543 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
544 defer cancel()
545
546 content, exists, err := s.passShow(ctx, manifestPath)
547 if err != nil {
548 return nil, err
549 }
550 if !exists {
551 return []string{}, nil
552 }
553
554 keys := []string{}
555 for _, line := range strings.Split(content, "\n") {
556 key := strings.TrimSpace(line)
557 if key == "" {
558 continue
559 }
560 keys = append(keys, key)
561 }
562
563 return keys, nil
564}
565
566func (s *PassStore) sanitizeDomain(domainName string) (string, error) {
567 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
568 if err != nil {
569 s.log.Warn().
570 Str(zerowrap.FieldLayer, "adapter").
571 Str(zerowrap.FieldAdapter, "domainsecrets").
572 Str("domain", domainName).
573 Err(err).
574 Msg("rejected invalid domain")
575 return "", domain.ErrPathTraversal
576 }
577 return safeDomain, nil
578}
579
580// ManifestExists checks if the manifest exists for a domain.
581func (s *PassStore) ManifestExists(domainName string) (bool, error) {
582 manifestPath, err := s.manifestPath(domainName)
583 if err != nil {
584 return false, err
585 }
586
587 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
588 defer cancel()
589
590 _, exists, err := s.passShow(ctx, manifestPath)
591 if err != nil {
592 return false, err
593 }
594
595 return exists, nil
596}
597
598func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
599 cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
600 cmd.Stdin = strings.NewReader(value)
601 output, err := cmd.CombinedOutput()
602 if err != nil {
603 return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err)
604 }
605 return nil
606}
607
608func (s *PassStore) passRemove(ctx context.Context, path string) error {
609 cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path)
610 output, err := cmd.CombinedOutput()
611 if err != nil {
612 if passEntryMissing(string(output)) {
613 return nil
614 }
615 return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
616 }
617 return nil
618}
619
620func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) {
621 cmd := exec.CommandContext(ctx, "pass", "show", path)
622 output, err := cmd.CombinedOutput()
623 if err != nil {
624 if passEntryMissing(string(output)) {
625 return "", false, nil
626 }
627 return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)
628 }
629
630 clean := ansiRegex.ReplaceAllString(string(output), "")
631 clean = strings.TrimRight(clean, "\r\n")
632 return clean, true, nil
633}
634
635func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
636 if err := secrets.ValidatePath(basePath); err != nil {
637 return nil, err
638 }
639
640 cmd := exec.CommandContext(ctx, "pass", "ls", basePath)
641 output, err := cmd.CombinedOutput()
642 if err != nil {
643 if passEntryMissing(string(output)) {
644 return []string{}, nil
645 }
646 return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err)
647 }
648
649 entries := []string{}
650 for _, entry := range parsePassListOutput(basePath, string(output)) {
651 if entry.depth == 1 {
652 entries = append(entries, entry.name)
653 }
654 }
655
656 return entries, nil
657}
658
659type passListEntry struct {
660 name string
661 depth int
662}
663
664func parsePassListOutput(basePath, output string) []passListEntry {
665 lines := strings.Split(output, "\n")
666 entries := []passListEntry{}
667
668 for _, line := range lines {
669 line = ansiRegex.ReplaceAllString(line, "")
670 if strings.TrimSpace(line) == "" {
671 continue
672 }
673
674 depth := 0
675 prefixLoop:
676 for {
677 switch {
678 case strings.HasPrefix(line, "│ "):
679 line = strings.TrimPrefix(line, "│ ")
680 depth++
681 case strings.HasPrefix(line, "| "):
682 line = strings.TrimPrefix(line, "| ")
683 depth++
684 case strings.HasPrefix(line, " "):
685 line = strings.TrimPrefix(line, " ")
686 depth++
687 default:
688 break prefixLoop
689 }
690 }
691
692 switch {
693 case strings.HasPrefix(line, "├── "):
694 line = strings.TrimPrefix(line, "├── ")
695 depth++
696 case strings.HasPrefix(line, "└── "):
697 line = strings.TrimPrefix(line, "└── ")
698 depth++
699 case strings.HasPrefix(line, "|-- "):
700 line = strings.TrimPrefix(line, "|-- ")
701 depth++
702 case strings.HasPrefix(line, "`-- "):
703 line = strings.TrimPrefix(line, "`-- ")
704 depth++
705 }
706
707 name := strings.TrimSpace(line)
708 if name == "" || name == basePath {
709 continue
710 }
711 if depth == 0 {
712 continue
713 }
714
715 entries = append(entries, passListEntry{name: name, depth: depth})
716 }
717
718 return entries
719}
720
721func passEntryMissing(output string) bool {
722 clean := ansiRegex.ReplaceAllString(output, "")
723 lower := strings.ToLower(clean)
724 return strings.Contains(lower, "not in the password store") ||
725 strings.Contains(lower, "password store is empty")
726}
727
728func (s *PassStore) cleanupInsertedPaths(paths []string) {
729 if len(paths) == 0 {
730 return
731 }
732
733 for _, path := range paths {
734 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
735 _ = s.passRemove(ctx, path)
736 cancel()
737 }
738}