v2.31.0-dev20 020cc00

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	"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}

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