1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package gitremote lists the refs of repositories on github.com with
4// `git ls-remote`, for the resolution of actions (ports.GitRemote). It sends no
5// credentials, never prompts, and bounds the time and the output of git.
6package gitremote
7
8import (
9 "bytes"
10 "context"
11 "errors"
12 "fmt"
13 "os/exec"
14 "strings"
15 "time"
16
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"
19)
20
21// Defaults of the bounds.
22const (
23 // DefaultTimeout is how long one ls-remote may take.
24 DefaultTimeout = 20 * time.Second
25 // DefaultMaxOutput is the most bytes of ls-remote output read: a repository
26 // with 100,000 tags lists about 8 MB. More is refused as unavailable.
27 DefaultMaxOutput = 16 << 20
28 // DefaultBaseURL is where the repositories are.
29 DefaultBaseURL = "https://github.com"
30)
31
32// Config configures a Remote.
33type Config struct {
34 // Bin is the git executable. Required.
35 Bin string
36 // Path is the PATH given to git, which it uses to find its helpers.
37 Path string
38 // Home is the HOME of git; it should be an empty directory the process
39 // owns, so that no user configuration or credential is read.
40 Home string
41 // BaseURL, Timeout and MaxOutput default to the constants above; tests
42 // replace them.
43 BaseURL string
44 Timeout time.Duration
45 MaxOutput int
46 // AllowedProtocols is GIT_ALLOW_PROTOCOL: "https" by default.
47 AllowedProtocols string
48 // Proxy is the proxy environment forwarded to git, as NAME=value pairs: see
49 // ProxyEnv. Nothing else of the environment of ermite reaches git.
50 Proxy []string
51}
52
53// proxyVars are the variables git (through libcurl) reads to reach github.com
54// through a proxy. They are the only ones of the environment of ermite that
55// the ls-remote processes get.
56var proxyVars = []string{
57 "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "ALL_PROXY",
58 "https_proxy", "http_proxy", "no_proxy", "all_proxy",
59}
60
61// ProxyEnv picks the proxy variables from an environment lookup (os.Getenv),
62// for Config.Proxy. Empty ones are left out.
63func ProxyEnv(get func(string) string) []string {
64 var out []string
65 for _, name := range proxyVars {
66 if v := get(name); v != "" {
67 out = append(out, name+"="+v)
68 }
69 }
70 return out
71}
72
73// Remote implements ports.GitRemote over the git binary.
74type Remote struct{ cfg Config }
75
76var _ ports.GitRemote = (*Remote)(nil)
77
78// New builds a Remote.
79func New(cfg Config) *Remote {
80 if cfg.BaseURL == "" {
81 cfg.BaseURL = DefaultBaseURL
82 }
83 if cfg.Timeout <= 0 {
84 cfg.Timeout = DefaultTimeout
85 }
86 if cfg.MaxOutput <= 0 {
87 cfg.MaxOutput = DefaultMaxOutput
88 }
89 if cfg.AllowedProtocols == "" {
90 cfg.AllowedProtocols = "https"
91 }
92 return &Remote{cfg: cfg}
93}
94
95// validSegment reports whether s can be an owner or a repository name on
96// GitHub: letters, digits, '.', '_' and '-', not starting with '-' or '.'.
97func validSegment(s string) bool {
98 if s == "" || len(s) > 100 || s[0] == '-' || s[0] == '.' {
99 return false
100 }
101 for _, r := range s {
102 switch {
103 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
104 default:
105 return false
106 }
107 }
108 return true
109}
110
111// limitWriter keeps at most max bytes and records that more came.
112type limitWriter struct {
113 buf bytes.Buffer
114 max int
115 exceeded bool
116 cancel context.CancelFunc
117}
118
119func (w *limitWriter) Write(p []byte) (int, error) {
120 if w.buf.Len()+len(p) > w.max {
121 w.exceeded = true
122 w.cancel()
123 return 0, errors.New("output limit reached")
124 }
125 return w.buf.Write(p)
126}
127
128// ListRefs lists the branches and tags of owner/repo, with the peeled commit
129// of the annotated tags.
130func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
131 if !validSegment(owner) || !validSegment(repo) {
132 return nil, domain.ErrNotFound
133 }
134 ctx, cancel := context.WithTimeout(ctx, r.cfg.Timeout)
135 defer cancel()
136 out := &limitWriter{max: r.cfg.MaxOutput, cancel: cancel}
137 var stderr bytes.Buffer
138 url := strings.TrimSuffix(r.cfg.BaseURL, "/") + "/" + owner + "/" + repo
139 cmd := exec.CommandContext(ctx, r.cfg.Bin,
140 "-c", "credential.helper=", "-c", "core.askPass=", "-c", "http.followRedirects=false",
141 "ls-remote", "--", url, "refs/heads/*", "refs/tags/*")
142 cmd.Env = []string{
143 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "HOME=" + r.cfg.Home,
144 "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never",
145 "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path,
146 }
147 cmd.Env = append(cmd.Env, r.cfg.Proxy...)
148 cmd.Dir = r.cfg.Home
149 cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096}
150 cmd.WaitDelay = 2 * time.Second
151 err := cmd.Run()
152 switch {
153 case out.exceeded:
154 return nil, fmt.Errorf("%w: git ls-remote %s/%s listed more than %d bytes", domain.ErrUnavailable, owner, repo, r.cfg.MaxOutput)
155 case errors.Is(ctx.Err(), context.DeadlineExceeded):
156 return nil, fmt.Errorf("%w: git ls-remote %s/%s took more than %s", domain.ErrUnavailable, owner, repo, r.cfg.Timeout)
157 case ctx.Err() != nil:
158 return nil, ctx.Err()
159 case err != nil:
160 msg := stderr.String()
161 if notFound(msg) {
162 return nil, domain.ErrNotFound
163 }
164 return nil, fmt.Errorf("%w: git ls-remote %s/%s: %v", domain.ErrUnavailable, owner, repo, firstLine(msg))
165 }
166 return parse(out.buf.String()), nil
167}
168
169// notFound recognises what git says when github.com answers 404 or asks for
170// credentials, which it does for a repository that does not exist as for a
171// private one, and the prompt being disabled.
172func notFound(stderr string) bool {
173 for _, s := range []string{"Repository not found", "terminal prompts disabled", "could not read Username", "Authentication failed", "returned error: 404"} {
174 if strings.Contains(stderr, s) {
175 return true
176 }
177 }
178 return false
179}
180
181func firstLine(s string) string {
182 s = strings.TrimSpace(s)
183 if i := strings.IndexByte(s, '\n'); i >= 0 {
184 s = s[:i]
185 }
186 return s
187}
188
189// limitedBuffer keeps the first max bytes written and drops the rest.
190type limitedBuffer struct {
191 b *bytes.Buffer
192 max int
193}
194
195func (l *limitedBuffer) Write(p []byte) (int, error) {
196 if room := l.max - l.b.Len(); room > 0 {
197 l.b.Write(p[:min(room, len(p))])
198 }
199 return len(p), nil
200}
201
202// parse reads the lines "<id>\t<ref>" of ls-remote. A "<ref>^{}" line gives
203// the peeled commit of the annotated tag listed before it.
204func parse(out string) []ports.RemoteRef {
205 var refs []ports.RemoteRef
206 index := map[string]int{}
207 for line := range strings.SplitSeq(out, "\n") {
208 id, name, ok := strings.Cut(line, "\t")
209 if !ok || len(id) != domain.ObjectIDLen {
210 continue
211 }
212 if base, peeled := strings.CutSuffix(name, "^{}"); peeled {
213 if i, ok := index[base]; ok {
214 refs[i].Peeled = id
215 }
216 continue
217 }
218 index[name] = len(refs)
219 refs = append(refs, ports.RemoteRef{Name: name, ID: id})
220 }
221 return refs
222}