v2.10.1 43db1bb

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// GetAllAttachment returns all secrets for an attachment container as a key-value map.
303func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {
304	keys, err := s.listAttachmentKeys(containerName)
305	if err != nil {
306		return nil, err
307	}
308
309	secretsMap := make(map[string]string)
310	for _, key := range keys {
311		keyPath, err := s.attachmentKeyPath(containerName, key)
312		if err != nil {
313			return nil, err
314		}
315
316		ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
317		value, exists, err := s.passShow(ctx, keyPath)
318		cancel()
319		if err != nil {
320			return nil, err
321		}
322		if !exists {
323			s.log.Warn().
324				Str(zerowrap.FieldLayer, "adapter").
325				Str(zerowrap.FieldAdapter, "domainsecrets").
326				Str("container", containerName).
327				Str("key", key).
328				Msg("attachment secret listed in manifest but missing in pass")
329			continue
330		}
331		secretsMap[key] = value
332	}
333
334	return secretsMap, nil
335}
336
337// ListAttachmentKeys finds attachment secrets for a domain from pass.
338// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
339func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
340	if _, err := s.domainPath(domainName); err != nil {
341		return nil, err
342	}
343
344	ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
345	defer cancel()
346
347	containers, err := s.listTopLevelEntries(ctx, PassAttachmentPath)
348	if err != nil {
349		return nil, err
350	}
351
352	// Try both new and legacy sanitization for backwards compatibility
353	sanitizedDomain := domain.SanitizeDomainForContainer(domainName)
354	sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
355	prefixes := []string{
356		"gordon-" + sanitizedDomain + "-",       // New format (collision-resistant)
357		"gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible)
358	}
359
360	seen := make(map[string]bool)
361	var results []out.AttachmentSecrets
362	for _, containerName := range containers {
363		matches := false
364		for _, prefix := range prefixes {
365			if strings.HasPrefix(containerName, prefix) {
366				matches = true
367				break
368			}
369		}
370		if !matches {
371			continue
372		}
373
374		// Deduplicate results
375		if seen[containerName] {
376			continue
377		}
378		seen[containerName] = true
379
380		manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName)
381		if err := secrets.ValidatePath(manifestPath); err != nil {
382			return nil, err
383		}
384
385		showCtx, showCancel := context.WithTimeout(context.Background(), s.timeout)
386		content, exists, err := s.passShow(showCtx, manifestPath)
387		showCancel()
388		if err != nil {
389			return nil, err
390		}
391		if !exists {
392			continue
393		}
394
395		keys := []string{}
396		for _, line := range strings.Split(content, "\n") {
397			key := strings.TrimSpace(line)
398			if key == "" {
399				continue
400			}
401			keys = append(keys, key)
402		}
403
404		if len(keys) > 0 {
405			results = append(results, out.AttachmentSecrets{
406				Service: containerName,
407				Keys:    keys,
408			})
409		}
410	}
411
412	return results, nil
413}
414
415func (s *PassStore) domainPath(domainName string) (string, error) {
416	safeDomain, err := s.sanitizeDomain(domainName)
417	if err != nil {
418		return "", err
419	}
420	path := fmt.Sprintf("%s/%s", PassDomainSecretsPath, safeDomain)
421	if err := secrets.ValidatePath(path); err != nil {
422		return "", err
423	}
424	return path, nil
425}
426
427func (s *PassStore) keyPath(domainName, key string) (string, error) {
428	domainPath, err := s.domainPath(domainName)
429	if err != nil {
430		return "", err
431	}
432	if err := domain.ValidateEnvKey(key); err != nil {
433		return "", err
434	}
435	path := fmt.Sprintf("%s/%s", domainPath, key)
436	if err := secrets.ValidatePath(path); err != nil {
437		return "", err
438	}
439	return path, nil
440}
441
442func (s *PassStore) manifestPath(domainName string) (string, error) {
443	domainPath, err := s.domainPath(domainName)
444	if err != nil {
445		return "", err
446	}
447	path := fmt.Sprintf("%s/.keys", domainPath)
448	if err := secrets.ValidatePath(path); err != nil {
449		return "", err
450	}
451	return path, nil
452}
453
454func (s *PassStore) attachmentPath(containerName string) (string, error) {
455	if err := domain.ValidateContainerName(containerName); err != nil {
456		return "", err
457	}
458	path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName)
459	if err := secrets.ValidatePath(path); err != nil {
460		return "", err
461	}
462	return path, nil
463}
464
465func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) {
466	attachmentPath, err := s.attachmentPath(containerName)
467	if err != nil {
468		return "", err
469	}
470	if err := domain.ValidateEnvKey(key); err != nil {
471		return "", err
472	}
473	path := fmt.Sprintf("%s/%s", attachmentPath, key)
474	if err := secrets.ValidatePath(path); err != nil {
475		return "", err
476	}
477	return path, nil
478}
479
480func (s *PassStore) attachmentManifestPath(containerName string) (string, error) {
481	attachmentPath, err := s.attachmentPath(containerName)
482	if err != nil {
483		return "", err
484	}
485	path := fmt.Sprintf("%s/.keys", attachmentPath)
486	if err := secrets.ValidatePath(path); err != nil {
487		return "", err
488	}
489	return path, nil
490}
491
492func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) {
493	manifestPath, err := s.attachmentManifestPath(containerName)
494	if err != nil {
495		return nil, err
496	}
497
498	ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
499	defer cancel()
500
501	content, exists, err := s.passShow(ctx, manifestPath)
502	if err != nil {
503		return nil, err
504	}
505	if !exists {
506		return []string{}, nil
507	}
508
509	keys := []string{}
510	for _, line := range strings.Split(content, "\n") {
511		key := strings.TrimSpace(line)
512		if key == "" {
513			continue
514		}
515		keys = append(keys, key)
516	}
517
518	return keys, nil
519}
520
521func (s *PassStore) sanitizeDomain(domainName string) (string, error) {
522	safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
523	if err != nil {
524		s.log.Warn().
525			Str(zerowrap.FieldLayer, "adapter").
526			Str(zerowrap.FieldAdapter, "domainsecrets").
527			Str("domain", domainName).
528			Err(err).
529			Msg("rejected invalid domain")
530		return "", domain.ErrPathTraversal
531	}
532	return safeDomain, nil
533}
534
535// ManifestExists checks if the manifest exists for a domain.
536func (s *PassStore) ManifestExists(domainName string) (bool, error) {
537	manifestPath, err := s.manifestPath(domainName)
538	if err != nil {
539		return false, err
540	}
541
542	ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
543	defer cancel()
544
545	_, exists, err := s.passShow(ctx, manifestPath)
546	if err != nil {
547		return false, err
548	}
549
550	return exists, nil
551}
552
553func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
554	cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
555	cmd.Stdin = strings.NewReader(value)
556	output, err := cmd.CombinedOutput()
557	if err != nil {
558		return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err)
559	}
560	return nil
561}
562
563func (s *PassStore) passRemove(ctx context.Context, path string) error {
564	cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path)
565	output, err := cmd.CombinedOutput()
566	if err != nil {
567		if passEntryMissing(string(output)) {
568			return nil
569		}
570		return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
571	}
572	return nil
573}
574
575func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) {
576	cmd := exec.CommandContext(ctx, "pass", "show", path)
577	output, err := cmd.CombinedOutput()
578	if err != nil {
579		if passEntryMissing(string(output)) {
580			return "", false, nil
581		}
582		return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)
583	}
584
585	clean := ansiRegex.ReplaceAllString(string(output), "")
586	clean = strings.TrimRight(clean, "\r\n")
587	return clean, true, nil
588}
589
590func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
591	if err := secrets.ValidatePath(basePath); err != nil {
592		return nil, err
593	}
594
595	cmd := exec.CommandContext(ctx, "pass", "ls", basePath)
596	output, err := cmd.CombinedOutput()
597	if err != nil {
598		if passEntryMissing(string(output)) {
599			return []string{}, nil
600		}
601		return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err)
602	}
603
604	entries := []string{}
605	for _, entry := range parsePassListOutput(basePath, string(output)) {
606		if entry.depth == 1 {
607			entries = append(entries, entry.name)
608		}
609	}
610
611	return entries, nil
612}
613
614type passListEntry struct {
615	name  string
616	depth int
617}
618
619func parsePassListOutput(basePath, output string) []passListEntry {
620	lines := strings.Split(output, "\n")
621	entries := []passListEntry{}
622
623	for _, line := range lines {
624		line = ansiRegex.ReplaceAllString(line, "")
625		if strings.TrimSpace(line) == "" {
626			continue
627		}
628
629		depth := 0
630	prefixLoop:
631		for {
632			switch {
633			case strings.HasPrefix(line, "│   "):
634				line = strings.TrimPrefix(line, "│   ")
635				depth++
636			case strings.HasPrefix(line, "|   "):
637				line = strings.TrimPrefix(line, "|   ")
638				depth++
639			case strings.HasPrefix(line, "    "):
640				line = strings.TrimPrefix(line, "    ")
641				depth++
642			default:
643				break prefixLoop
644			}
645		}
646
647		switch {
648		case strings.HasPrefix(line, "├── "):
649			line = strings.TrimPrefix(line, "├── ")
650			depth++
651		case strings.HasPrefix(line, "└── "):
652			line = strings.TrimPrefix(line, "└── ")
653			depth++
654		case strings.HasPrefix(line, "|-- "):
655			line = strings.TrimPrefix(line, "|-- ")
656			depth++
657		case strings.HasPrefix(line, "`-- "):
658			line = strings.TrimPrefix(line, "`-- ")
659			depth++
660		}
661
662		name := strings.TrimSpace(line)
663		if name == "" || name == basePath {
664			continue
665		}
666		if depth == 0 {
667			continue
668		}
669
670		entries = append(entries, passListEntry{name: name, depth: depth})
671	}
672
673	return entries
674}
675
676func passEntryMissing(output string) bool {
677	clean := ansiRegex.ReplaceAllString(output, "")
678	lower := strings.ToLower(clean)
679	return strings.Contains(lower, "not in the password store") ||
680		strings.Contains(lower, "password store is empty")
681}
682
683func (s *PassStore) cleanupInsertedPaths(paths []string) {
684	if len(paths) == 0 {
685		return
686	}
687
688	for _, path := range paths {
689		ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
690		_ = s.passRemove(ctx, path)
691		cancel()
692	}
693}

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