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 "sync"
12 "time"
13
14 "github.com/bnema/zerowrap"
15
16 "github.com/bnema/gordon/internal/adapters/out/secrets"
17 "github.com/bnema/gordon/internal/boundaries/out"
18 "github.com/bnema/gordon/internal/domain"
19)
20
21// ansiRegex matches ANSI escape sequences for stripping from pass output.
22var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
23
24const (
25 PassDomainSecretsPath = "gordon/env" //nolint:gosec // Not a credential, this is a pass store path.
26 PassAttachmentPath = "gordon/env/attachments" //nolint:gosec // Not a credential, this is a pass store path.
27)
28
29// PassStore implements the DomainSecretStore interface using the pass password manager.
30type PassStore struct {
31 mu sync.Mutex
32 timeout time.Duration
33 log zerowrap.Logger
34}
35
36// NewPassStore creates a new pass-based domain secret store.
37func NewPassStore(log zerowrap.Logger) (*PassStore, error) {
38 // Use a timeout so a stalled GPG agent or keyring does not hang startup.
39 probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second)
40 defer probeCancel()
41 if err := exec.CommandContext(probeCtx, "pass", "version").Run(); err != nil { //nolint:gosec // binary is a constant ("pass"), no user input
42 return nil, fmt.Errorf("pass is not available: %w", err)
43 }
44
45 log.Debug().
46 Str(zerowrap.FieldLayer, "adapter").
47 Str(zerowrap.FieldAdapter, "domainsecrets").
48 Str("provider", "pass").
49 Msg("domain secret store initialized")
50
51 return &PassStore{
52 timeout: 10 * time.Second,
53 log: log,
54 }, nil
55}
56
57// ListKeys returns the list of secret keys for a domain (not values).
58func (s *PassStore) ListKeys(domainName string) ([]string, error) {
59 manifestPath, err := s.manifestPath(domainName)
60 if err != nil {
61 return nil, err
62 }
63
64 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
65 defer cancel()
66
67 content, exists, err := s.passShow(ctx, manifestPath)
68 if err != nil {
69 return nil, err
70 }
71 keys := []string{}
72 if exists {
73 for _, line := range strings.Split(content, "\n") {
74 key := strings.TrimSpace(line)
75 if key == "" {
76 continue
77 }
78 keys = append(keys, key)
79 }
80 }
81 sort.Strings(keys)
82
83 // Recover keys that may exist in pass but are missing from the manifest.
84 discovered, err := s.listDomainKeys(domainName)
85 if err != nil {
86 return nil, err
87 }
88
89 merged, changed := mergeUniqueKeys(keys, discovered)
90 if changed {
91 // Best-effort self-heal of stale manifest.
92 writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout)
93 writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n"))
94 writeCancel()
95 if writeErr != nil {
96 s.log.Warn().
97 Str(zerowrap.FieldLayer, "adapter").
98 Str(zerowrap.FieldAdapter, "domainsecrets").
99 Str("domain", domainName).
100 Err(writeErr).
101 Msg("failed to self-heal pass manifest, continuing with recovered keys")
102 }
103 }
104
105 return merged, nil
106}
107
108// GetAll returns all secrets for a domain as a key-value map.
109func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
110 keys, err := s.ListKeys(domainName)
111 if err != nil {
112 return nil, err
113 }
114
115 secretsMap := make(map[string]string)
116 for _, key := range keys {
117 keyPath, err := s.keyPath(domainName, key)
118 if err != nil {
119 return nil, err
120 }
121
122 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
123 value, exists, err := s.passShow(ctx, keyPath)
124 cancel()
125 if err != nil {
126 return nil, err
127 }
128 if !exists {
129 s.log.Warn().
130 Str(zerowrap.FieldLayer, "adapter").
131 Str(zerowrap.FieldAdapter, "domainsecrets").
132 Str("domain", domainName).
133 Str("key", key).
134 Msg("secret listed in manifest but missing in pass")
135 continue
136 }
137 secretsMap[key] = value
138 }
139
140 return secretsMap, nil
141}
142
143// SetIfEmpty sets multiple secrets for a domain only when no keys already exist.
144// It performs the precondition check inside the store adapter so migration
145// callers cannot accidentally split the check and write into separate steps.
146func (s *PassStore) SetIfEmpty(domainName string, secretsMap map[string]string) ([]string, error) {
147 s.mu.Lock()
148 defer s.mu.Unlock()
149
150 existingKeys, err := s.ListKeys(domainName)
151 if err != nil {
152 return nil, err
153 }
154 if len(existingKeys) > 0 {
155 return nil, fmt.Errorf("%w: pass secrets already exist for %s (found %d keys)", domain.ErrSecretsAlreadyExist, domainName, len(existingKeys))
156 }
157 if err := s.Set(domainName, secretsMap); err != nil {
158 return nil, err
159 }
160 return sortedMapKeys(secretsMap), nil
161}
162
163// Set sets or updates multiple secrets for a domain.
164func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
165 if _, err := s.domainPath(domainName); err != nil {
166 return err
167 }
168
169 existingKeys, err := s.ListKeys(domainName)
170 if err != nil {
171 return err
172 }
173
174 existingSet := make(map[string]struct{}, len(existingKeys))
175 for _, key := range existingKeys {
176 existingSet[key] = struct{}{}
177 }
178
179 insertedNewPaths := make([]string, 0, len(secretsMap))
180 for key, value := range secretsMap {
181 if err := domain.ValidateEnvKey(key); err != nil {
182 return err
183 }
184
185 keyPath, err := s.keyPath(domainName, key)
186 if err != nil {
187 return err
188 }
189
190 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
191 err = s.passInsert(ctx, keyPath, value)
192 cancel()
193 if err != nil {
194 s.cleanupInsertedPaths(insertedNewPaths)
195 return fmt.Errorf("failed to store secret %s: %w", key, err)
196 }
197
198 if _, exists := existingSet[key]; !exists {
199 insertedNewPaths = append(insertedNewPaths, keyPath)
200 }
201 }
202
203 keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
204 for _, key := range existingKeys {
205 keySet[key] = struct{}{}
206 }
207 for key := range secretsMap {
208 keySet[key] = struct{}{}
209 }
210
211 keys := make([]string, 0, len(keySet))
212 for key := range keySet {
213 keys = append(keys, key)
214 }
215 sort.Strings(keys)
216
217 manifestPath, err := s.manifestPath(domainName)
218 if err != nil {
219 return err
220 }
221
222 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
223 if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
224 cancel()
225 s.cleanupInsertedPaths(insertedNewPaths)
226 return fmt.Errorf("failed to update manifest: %w", err)
227 }
228 cancel()
229
230 return nil
231}
232
233// Delete removes a specific secret key from a domain.
234func (s *PassStore) Delete(domainName, key string) error {
235 if _, err := s.domainPath(domainName); err != nil {
236 return err
237 }
238
239 keyPath, err := s.keyPath(domainName, key)
240 if err != nil {
241 return err
242 }
243
244 rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
245 defer rmCancel()
246 if err := s.passRemove(rmCtx, keyPath); err != nil {
247 return err
248 }
249
250 keys, err := s.ListKeys(domainName)
251 if err != nil {
252 return err
253 }
254
255 updated := make([]string, 0, len(keys))
256 for _, existingKey := range keys {
257 if existingKey == key {
258 continue
259 }
260 updated = append(updated, existingKey)
261 }
262 sort.Strings(updated)
263
264 manifestPath, err := s.manifestPath(domainName)
265 if err != nil {
266 return err
267 }
268
269 insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
270 defer insertCancel()
271 if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
272 return fmt.Errorf("failed to update manifest: %w", err)
273 }
274
275 return nil
276}
277
278// SetAttachmentIfEmpty sets attachment secrets only when no keys already exist.
279// It performs the precondition check inside the store adapter so migration
280// callers cannot accidentally split the check and write into separate steps.
281func (s *PassStore) SetAttachmentIfEmpty(containerName string, secretsMap map[string]string) ([]string, error) {
282 s.mu.Lock()
283 defer s.mu.Unlock()
284
285 existingKeys, err := s.listAttachmentKeysRecover(containerName)
286 if err != nil {
287 return nil, err
288 }
289 if len(existingKeys) > 0 {
290 return nil, fmt.Errorf("%w: pass secrets already exist for attachment %s (found %d keys)", domain.ErrSecretsAlreadyExist, containerName, len(existingKeys))
291 }
292 if err := s.SetAttachment(containerName, secretsMap); err != nil {
293 return nil, err
294 }
295 return sortedMapKeys(secretsMap), nil
296}
297
298// SetAttachment sets or updates multiple secrets for an attachment container.
299func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error {
300 if _, err := s.attachmentPath(containerName); err != nil {
301 return err
302 }
303
304 existingKeys, err := s.listAttachmentKeys(containerName)
305 if err != nil {
306 return err
307 }
308
309 existingSet := make(map[string]struct{}, len(existingKeys))
310 for _, key := range existingKeys {
311 existingSet[key] = struct{}{}
312 }
313
314 insertedNewPaths := make([]string, 0, len(secretsMap))
315 for key, value := range secretsMap {
316 if err := domain.ValidateEnvKey(key); err != nil {
317 return err
318 }
319
320 keyPath, err := s.attachmentKeyPath(containerName, key)
321 if err != nil {
322 return err
323 }
324
325 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
326 err = s.passInsert(ctx, keyPath, value)
327 cancel()
328 if err != nil {
329 s.cleanupInsertedPaths(insertedNewPaths)
330 return fmt.Errorf("failed to store attachment secret %s: %w", key, err)
331 }
332
333 if _, exists := existingSet[key]; !exists {
334 insertedNewPaths = append(insertedNewPaths, keyPath)
335 }
336 }
337
338 keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
339 for _, key := range existingKeys {
340 keySet[key] = struct{}{}
341 }
342 for key := range secretsMap {
343 keySet[key] = struct{}{}
344 }
345
346 keys := make([]string, 0, len(keySet))
347 for key := range keySet {
348 keys = append(keys, key)
349 }
350 sort.Strings(keys)
351
352 manifestPath, err := s.attachmentManifestPath(containerName)
353 if err != nil {
354 return err
355 }
356
357 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
358 if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
359 cancel()
360 s.cleanupInsertedPaths(insertedNewPaths)
361 return fmt.Errorf("failed to update attachment manifest: %w", err)
362 }
363 cancel()
364
365 return nil
366}
367
368// DeleteAttachment removes a specific secret key from an attachment container.
369func (s *PassStore) DeleteAttachment(containerName, key string) error {
370 if _, err := s.attachmentPath(containerName); err != nil {
371 return err
372 }
373
374 keyPath, err := s.attachmentKeyPath(containerName, key)
375 if err != nil {
376 return err
377 }
378
379 rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
380 defer rmCancel()
381 if err := s.passRemove(rmCtx, keyPath); err != nil {
382 return err
383 }
384
385 keys, err := s.listAttachmentKeys(containerName)
386 if err != nil {
387 return err
388 }
389
390 updated := make([]string, 0, len(keys))
391 for _, existingKey := range keys {
392 if existingKey == key {
393 continue
394 }
395 updated = append(updated, existingKey)
396 }
397 sort.Strings(updated)
398
399 manifestPath, err := s.attachmentManifestPath(containerName)
400 if err != nil {
401 return err
402 }
403
404 insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
405 defer insertCancel()
406 if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
407 return fmt.Errorf("failed to update attachment manifest: %w", err)
408 }
409
410 return nil
411}
412
413// GetAllAttachment returns all secrets for an attachment container as a key-value map.
414func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {
415 keys, err := s.listAttachmentKeys(containerName)
416 if err != nil {
417 return nil, err
418 }
419
420 secretsMap := make(map[string]string)
421 for _, key := range keys {
422 keyPath, err := s.attachmentKeyPath(containerName, key)
423 if err != nil {
424 return nil, err
425 }
426
427 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
428 value, exists, err := s.passShow(ctx, keyPath)
429 cancel()
430 if err != nil {
431 return nil, err
432 }
433 if !exists {
434 s.log.Warn().
435 Str(zerowrap.FieldLayer, "adapter").
436 Str(zerowrap.FieldAdapter, "domainsecrets").
437 Str("container", containerName).
438 Str("key", key).
439 Msg("attachment secret listed in manifest but missing in pass")
440 continue
441 }
442 secretsMap[key] = value
443 }
444
445 return secretsMap, nil
446}
447
448// ListAttachmentKeys finds attachment secrets for a domain from pass.
449// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
450func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
451 if _, err := s.domainPath(domainName); err != nil {
452 return nil, err
453 }
454
455 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
456 defer cancel()
457
458 containers, err := s.listTopLevelEntries(ctx, PassAttachmentPath)
459 if err != nil {
460 return nil, err
461 }
462
463 // Try both new and legacy sanitization for backwards compatibility
464 sanitizedDomain := domain.SanitizeDomainForContainer(domainName)
465 sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
466 prefixes := []string{
467 "gordon-" + sanitizedDomain + "-", // New format (collision-resistant)
468 "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible)
469 }
470
471 seen := make(map[string]bool)
472 var results []out.AttachmentSecrets
473 for _, containerName := range containers {
474 matches := false
475 for _, prefix := range prefixes {
476 if strings.HasPrefix(containerName, prefix) {
477 matches = true
478 break
479 }
480 }
481 if !matches {
482 continue
483 }
484
485 // Deduplicate results
486 if seen[containerName] {
487 continue
488 }
489 seen[containerName] = true
490
491 manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName)
492 if err := secrets.ValidatePath(manifestPath); err != nil {
493 return nil, err
494 }
495
496 showCtx, showCancel := context.WithTimeout(context.Background(), s.timeout)
497 content, exists, err := s.passShow(showCtx, manifestPath)
498 showCancel()
499 if err != nil {
500 return nil, err
501 }
502 if !exists {
503 continue
504 }
505
506 keys := []string{}
507 for _, line := range strings.Split(content, "\n") {
508 key := strings.TrimSpace(line)
509 if key == "" {
510 continue
511 }
512 keys = append(keys, key)
513 }
514
515 if len(keys) > 0 {
516 results = append(results, out.AttachmentSecrets{
517 Service: containerName,
518 Keys: keys,
519 })
520 }
521 }
522
523 return results, nil
524}
525
526func (s *PassStore) domainPath(domainName string) (string, error) {
527 safeDomain, err := s.sanitizeDomain(domainName)
528 if err != nil {
529 return "", err
530 }
531 path := fmt.Sprintf("%s/%s", PassDomainSecretsPath, safeDomain)
532 if err := secrets.ValidatePath(path); err != nil {
533 return "", err
534 }
535 return path, nil
536}
537
538func (s *PassStore) keyPath(domainName, key string) (string, error) {
539 domainPath, err := s.domainPath(domainName)
540 if err != nil {
541 return "", err
542 }
543 if err := domain.ValidateEnvKey(key); err != nil {
544 return "", err
545 }
546 path := fmt.Sprintf("%s/%s", domainPath, key)
547 if err := secrets.ValidatePath(path); err != nil {
548 return "", err
549 }
550 return path, nil
551}
552
553func (s *PassStore) manifestPath(domainName string) (string, error) {
554 domainPath, err := s.domainPath(domainName)
555 if err != nil {
556 return "", err
557 }
558 path := fmt.Sprintf("%s/.keys", domainPath)
559 if err := secrets.ValidatePath(path); err != nil {
560 return "", err
561 }
562 return path, nil
563}
564
565func (s *PassStore) listDomainKeys(domainName string) ([]string, error) {
566 basePath, err := s.domainPath(domainName)
567 if err != nil {
568 return nil, err
569 }
570
571 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
572 defer cancel()
573
574 entries, err := s.listTopLevelEntries(ctx, basePath)
575 if err != nil {
576 return nil, err
577 }
578
579 keys := make([]string, 0, len(entries))
580 for _, entry := range entries {
581 if entry == ".keys" {
582 continue
583 }
584 if err := domain.ValidateEnvKey(entry); err != nil {
585 continue
586 }
587 keys = append(keys, entry)
588 }
589
590 sort.Strings(keys)
591 return keys, nil
592}
593
594func (s *PassStore) attachmentPath(containerName string) (string, error) {
595 if err := domain.ValidateContainerName(containerName); err != nil {
596 return "", err
597 }
598 path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName)
599 if err := secrets.ValidatePath(path); err != nil {
600 return "", err
601 }
602 return path, nil
603}
604
605func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) {
606 attachmentPath, err := s.attachmentPath(containerName)
607 if err != nil {
608 return "", err
609 }
610 if err := domain.ValidateEnvKey(key); err != nil {
611 return "", err
612 }
613 path := fmt.Sprintf("%s/%s", attachmentPath, key)
614 if err := secrets.ValidatePath(path); err != nil {
615 return "", err
616 }
617 return path, nil
618}
619
620func (s *PassStore) attachmentManifestPath(containerName string) (string, error) {
621 attachmentPath, err := s.attachmentPath(containerName)
622 if err != nil {
623 return "", err
624 }
625 path := fmt.Sprintf("%s/.keys", attachmentPath)
626 if err := secrets.ValidatePath(path); err != nil {
627 return "", err
628 }
629 return path, nil
630}
631
632func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) {
633 manifestPath, err := s.attachmentManifestPath(containerName)
634 if err != nil {
635 return nil, err
636 }
637
638 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
639 defer cancel()
640
641 content, exists, err := s.passShow(ctx, manifestPath)
642 if err != nil {
643 return nil, err
644 }
645 if !exists {
646 return []string{}, nil
647 }
648
649 keys := []string{}
650 for _, line := range strings.Split(content, "\n") {
651 key := strings.TrimSpace(line)
652 if key == "" {
653 continue
654 }
655 keys = append(keys, key)
656 }
657
658 sort.Strings(keys)
659 return keys, nil
660}
661
662func (s *PassStore) listAttachmentKeysRecover(containerName string) ([]string, error) {
663 keys, err := s.listAttachmentKeys(containerName)
664 if err != nil {
665 return nil, err
666 }
667 discovered, err := s.discoverAttachmentKeys(containerName)
668 if err != nil {
669 return nil, err
670 }
671 merged, changed := mergeUniqueKeys(keys, discovered)
672 if changed {
673 manifestPath, err := s.attachmentManifestPath(containerName)
674 if err != nil {
675 return nil, err
676 }
677 writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout)
678 writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n"))
679 writeCancel()
680 if writeErr != nil {
681 s.log.Warn().
682 Str(zerowrap.FieldLayer, "adapter").
683 Str(zerowrap.FieldAdapter, "domainsecrets").
684 Str("container", containerName).
685 Err(writeErr).
686 Msg("failed to self-heal attachment pass manifest, continuing with recovered keys")
687 }
688 }
689 return merged, nil
690}
691
692func (s *PassStore) discoverAttachmentKeys(containerName string) ([]string, error) {
693 basePath, err := s.attachmentPath(containerName)
694 if err != nil {
695 return nil, err
696 }
697
698 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
699 defer cancel()
700
701 entries, err := s.listTopLevelEntries(ctx, basePath)
702 if err != nil {
703 return nil, err
704 }
705
706 keys := make([]string, 0, len(entries))
707 for _, entry := range entries {
708 if entry == ".keys" {
709 continue
710 }
711 if err := domain.ValidateEnvKey(entry); err != nil {
712 continue
713 }
714 keys = append(keys, entry)
715 }
716
717 sort.Strings(keys)
718 return keys, nil
719}
720
721func (s *PassStore) sanitizeDomain(domainName string) (string, error) {
722 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
723 if err != nil {
724 s.log.Warn().
725 Str(zerowrap.FieldLayer, "adapter").
726 Str(zerowrap.FieldAdapter, "domainsecrets").
727 Str("domain", domainName).
728 Err(err).
729 Msg("rejected invalid domain")
730 return "", domain.ErrPathTraversal
731 }
732 return safeDomain, nil
733}
734
735// ManifestExists checks if the manifest exists for a domain.
736func (s *PassStore) ManifestExists(domainName string) (bool, error) {
737 manifestPath, err := s.manifestPath(domainName)
738 if err != nil {
739 return false, err
740 }
741
742 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
743 defer cancel()
744
745 _, exists, err := s.passShow(ctx, manifestPath)
746 if err != nil {
747 return false, err
748 }
749
750 return exists, nil
751}
752
753func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
754 cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator
755 cmd.Stdin = strings.NewReader(value)
756 output, err := cmd.CombinedOutput()
757 if err != nil {
758 return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err)
759 }
760 return nil
761}
762
763func (s *PassStore) passRemove(ctx context.Context, path string) error {
764 cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator
765 output, err := cmd.CombinedOutput()
766 if err != nil {
767 if passEntryMissing(string(output)) {
768 return nil
769 }
770 return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
771 }
772 return nil
773}
774
775func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) {
776 cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator
777 output, err := cmd.CombinedOutput()
778 if err != nil {
779 if passEntryMissing(string(output)) {
780 return "", false, nil
781 }
782 return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)
783 }
784
785 clean := ansiRegex.ReplaceAllString(string(output), "")
786 clean = strings.TrimRight(clean, "\r\n")
787 return clean, true, nil
788}
789
790func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
791 if err := secrets.ValidatePath(basePath); err != nil {
792 return nil, err
793 }
794
795 cmd := exec.CommandContext(ctx, "pass", "ls", basePath) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator
796 output, err := cmd.CombinedOutput()
797 if err != nil {
798 if passEntryMissing(string(output)) {
799 return []string{}, nil
800 }
801 return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err)
802 }
803
804 entries := []string{}
805 for _, entry := range parsePassListOutput(basePath, string(output)) {
806 if entry.depth == 1 {
807 entries = append(entries, entry.name)
808 }
809 }
810
811 return entries, nil
812}
813
814type passListEntry struct {
815 name string
816 depth int
817}
818
819func parsePassListOutput(basePath, output string) []passListEntry {
820 lines := strings.Split(output, "\n")
821 entries := []passListEntry{}
822
823 for _, line := range lines {
824 line = ansiRegex.ReplaceAllString(line, "")
825 if strings.TrimSpace(line) == "" {
826 continue
827 }
828
829 depth := 0
830 prefixLoop:
831 for {
832 switch {
833 case strings.HasPrefix(line, "│ "):
834 line = strings.TrimPrefix(line, "│ ")
835 depth++
836 case strings.HasPrefix(line, "| "):
837 line = strings.TrimPrefix(line, "| ")
838 depth++
839 case strings.HasPrefix(line, " "):
840 line = strings.TrimPrefix(line, " ")
841 depth++
842 default:
843 break prefixLoop
844 }
845 }
846
847 switch {
848 case strings.HasPrefix(line, "├── "):
849 line = strings.TrimPrefix(line, "├── ")
850 depth++
851 case strings.HasPrefix(line, "└── "):
852 line = strings.TrimPrefix(line, "└── ")
853 depth++
854 case strings.HasPrefix(line, "|-- "):
855 line = strings.TrimPrefix(line, "|-- ")
856 depth++
857 case strings.HasPrefix(line, "`-- "):
858 line = strings.TrimPrefix(line, "`-- ")
859 depth++
860 }
861
862 name := strings.TrimSpace(line)
863 if name == "" || name == basePath {
864 continue
865 }
866 if depth == 0 {
867 continue
868 }
869
870 entries = append(entries, passListEntry{name: name, depth: depth})
871 }
872
873 return entries
874}
875
876func passEntryMissing(output string) bool {
877 clean := ansiRegex.ReplaceAllString(output, "")
878 lower := strings.ToLower(clean)
879 return strings.Contains(lower, "not in the password store") ||
880 strings.Contains(lower, "password store is empty")
881}
882
883func sortedMapKeys(values map[string]string) []string {
884 keys := make([]string, 0, len(values))
885 for key := range values {
886 keys = append(keys, key)
887 }
888 sort.Strings(keys)
889 return keys
890}
891
892func mergeUniqueKeys(primary, secondary []string) ([]string, bool) {
893 mergedSet := make(map[string]struct{}, len(primary)+len(secondary))
894 for _, key := range primary {
895 mergedSet[key] = struct{}{}
896 }
897 for _, key := range secondary {
898 mergedSet[key] = struct{}{}
899 }
900
901 merged := make([]string, 0, len(mergedSet))
902 for key := range mergedSet {
903 merged = append(merged, key)
904 }
905 sort.Strings(merged)
906
907 if len(merged) != len(primary) {
908 return merged, true
909 }
910
911 for i := range primary {
912 if primary[i] != merged[i] {
913 return merged, true
914 }
915 }
916
917 return merged, false
918}
919
920func (s *PassStore) cleanupInsertedPaths(paths []string) {
921 if len(paths) == 0 {
922 return
923 }
924
925 for _, path := range paths {
926 // Wrap in a closure so defer cancel() runs at the end of each iteration,
927 // not at the end of cleanupInsertedPaths (which would delay all cancels).
928 func(p string) {
929 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
930 defer cancel()
931 _ = s.passRemove(ctx, p)
932 }(path)
933 }
934}