v2.11.0 dca6537

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

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