1package remote
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "os/exec"
8 "regexp"
9 "strings"
10 "sync"
11
12 "github.com/bnema/gordon/internal/domain"
13)
14
15var (
16 passAvailableOnce sync.Once
17 passAvailableResult bool
18 passWarnOnce sync.Once
19 validRemoteName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
20)
21
22func validateRemoteName(name string) error {
23 if name == "" {
24 return domain.ErrEmptyRemoteName
25 }
26 if !validRemoteName.MatchString(name) {
27 return fmt.Errorf("%w (allowed: a-z, A-Z, 0-9, '.', '_', '-')", domain.ErrInvalidRemoteNameChar)
28 }
29 if strings.Contains(name, "..") {
30 return domain.ErrConsecutiveDots
31 }
32 return nil
33}
34
35func passAvailable() bool {
36 passAvailableOnce.Do(func() {
37 path, err := exec.LookPath("pass")
38 if err != nil || path == "" {
39 passAvailableResult = false
40 return
41 }
42
43 cmd := exec.Command("pass", "ls") //nolint:gosec // pass binary name is constant
44 if err := cmd.Run(); err != nil {
45 passAvailableResult = false
46 return
47 }
48
49 passAvailableResult = true
50 })
51
52 return passAvailableResult
53}
54
55func passTokenPath(name string) string {
56 return "gordon/remotes/" + name + "/token"
57}
58
59func passReadToken(name string) (string, error) {
60 if err := validateRemoteName(name); err != nil {
61 return "", err
62 }
63 if !passAvailable() {
64 return "", domain.ErrPassUnavailable
65 }
66
67 cmd := exec.Command("pass", "show", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
68 var stdout bytes.Buffer
69 var stderr bytes.Buffer
70 cmd.Stdout = &stdout
71 cmd.Stderr = &stderr
72 if err := cmd.Run(); err != nil {
73 return "", fmt.Errorf("pass show failed: %w: %s", err, stderr.String())
74 }
75
76 return strings.TrimSpace(stdout.String()), nil
77}
78
79func passWriteToken(name, token string) error {
80 if err := validateRemoteName(name); err != nil {
81 return err
82 }
83 if !passAvailable() {
84 return domain.ErrPassUnavailable
85 }
86
87 cmd := exec.Command("pass", "insert", "--multiline", "--force", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
88 cmd.Stdin = strings.NewReader(token + "\n")
89 var stderr bytes.Buffer
90 cmd.Stderr = &stderr
91 if err := cmd.Run(); err != nil {
92 return fmt.Errorf("pass insert failed: %w: %s", err, stderr.String())
93 }
94
95 return nil
96}
97
98func passDeleteToken(name string) error {
99 if err := validateRemoteName(name); err != nil {
100 return err
101 }
102 if !passAvailable() {
103 return nil // nothing to delete
104 }
105
106 cmd := exec.Command("pass", "rm", "-f", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
107 var stderr bytes.Buffer
108 cmd.Stderr = &stderr
109 if err := cmd.Run(); err != nil {
110 return fmt.Errorf("pass rm failed: %w: %s", err, stderr.String())
111 }
112 return nil
113}
114
115func warnPassUnavailable() {
116 passWarnOnce.Do(func() {
117 fmt.Fprintf(os.Stderr, "Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.\n")
118 })
119}