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