v2.30.0 3ee801c

pass_store.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close