1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "context"
7 "encoding/base64"
8 "errors"
9 "fmt"
10 "io"
11 "math/rand/v2"
12 "net/http"
13 "os"
14 "path/filepath"
15 "slices"
16 "time"
17
18 "github.com/google/uuid"
19
20 "bnema.dev/ermite-runner/internal/urlorigin"
21)
22
23// Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760).
24const (
25 downloadAttempts = 3
26 downloadTimeout = 20 * time.Minute
27 // maxDownloadBytes bounds an archive (the runner has no bound).
28 maxDownloadBytes = 1 << 30
29)
30
31// noBackoffEnv is the runner's switch for tests (ActionManager.cs:1739).
32const noBackoffEnv = "_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF"
33
34// errActionNotFound is ActionNotFoundException (ActionNotFoundException.cs:28).
35type errActionNotFound struct{ url string }
36
37func (e errActionNotFound) Error() string {
38 return fmt.Sprintf("An action could not be found at the URI '%s'", e.url)
39}
40
41// errAccessDenied is AccessDeniedException; the request id is empty (ermite
42// has none) and the runner still prints its parentheses.
43type errAccessDenied struct{ url string }
44
45func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) }
46
47// originOf is the canonical origin of a URL (urlorigin.Canonical): the one the
48// host and the executor both compare, scheme, host and port with the default
49// port filled in, "" for anything that is not an absolute http(s) URL.
50func originOf(raw string) string { return urlorigin.Canonical(raw) }
51
52// tokenAllowed reports whether the token of ra may be sent to its tarball URL:
53// the URL is on exactly the origin the host named, compared in canonical form
54// (default ports filled in, IPv6 hosts in brackets).
55func tokenAllowed(ra ResolvedAction) bool {
56 o := originOf(ra.AuthOrigin)
57 return ra.Token != "" && o != "" && o == originOf(ra.TarballURL)
58}
59
60// archiveClient returns the client that downloads the archives. It follows
61// redirects (GitHub's codeload does redirect) but never carries credentials
62// across an origin: the Authorization header is dropped as soon as the scheme,
63// the host or the port changes, and a redirect from https to http is refused.
64// (net/http alone would keep the header for subdomains and ignore the scheme and
65// the port.) Tests may supply their own transport through Actions.HTTPClient;
66// its redirect policy is replaced.
67func (j *job) archiveClient() *http.Client {
68 c := &http.Client{}
69 if base := j.cfg.Actions.HTTPClient; base != nil {
70 cp := *base
71 c = &cp
72 }
73 c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
74 if len(via) >= 10 {
75 return errors.New("stopped after 10 redirects")
76 }
77 // Go builds every redirected request from the headers of the first one
78 // (copying Authorization when the host matches), so the header must be
79 // removed again on every hop that is not on the origin the token was meant
80 // for: the first request's. Once the chain left that origin, it never gets
81 // the header back, even if a later hop returns to it (A -> B -> A would
82 // otherwise be re-authorised by the copy).
83 origin := originOf(via[0].URL.String())
84 chain := slices.Concat(via[1:], []*http.Request{req}) // not append: via belongs to the client
85 for i, r := range chain {
86 prev := via[i]
87 if prev.URL.Scheme == "https" && r.URL.Scheme != "https" {
88 return fmt.Errorf("refusing to follow a redirect from https to %s", r.URL.Scheme)
89 }
90 }
91 for _, r := range chain {
92 if originOf(r.URL.String()) != origin {
93 req.Header.Del("Authorization")
94 break
95 }
96 }
97 return nil
98 }
99 return c
100}
101
102// sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper.
103// GetRandomBackoff), or what the test hook says.
104func (j *job) sleepBackoff(ctx context.Context) time.Duration {
105 if d := j.cfg.Actions.Backoff; d != nil {
106 return d()
107 }
108 return time.Duration(10+rand.Int64N(21)) * time.Second
109}
110
111// downloadArchive saves the archive of an action in file, retrying like the
112// runner: three attempts, a warning after each failure, a 404 or a 403 at once.
113// The final error is the one of the last attempt, which is what the runner
114// shows (PrepareActionsAsync prints the inner exception of
115// FailedToDownloadActionException).
116func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, file string) error {
117 url := ra.TarballURL
118 var last error
119 for attempt := 1; attempt <= downloadAttempts; attempt++ {
120 err := j.fetch(ctx, ra, file)
121 if err == nil {
122 return nil
123 }
124 last = err
125 var nf errActionNotFound
126 var ad errAccessDenied
127 if ctx.Err() != nil || errors.As(err, &ad) {
128 return err
129 }
130 if errors.As(err, &nf) {
131 return err
132 }
133 if attempt == downloadAttempts {
134 break
135 }
136 j.tagged(num, "##[warning]", fmt.Sprintf("Failed to download action '%s'. Error: %s ", url, err.Error()))
137 if os.Getenv(noBackoffEnv) == "" {
138 d := j.sleepBackoff(ctx)
139 j.tagged(num, "##[warning]", fmt.Sprintf("Back off %v seconds before retry.", d.Seconds()))
140 select {
141 case <-time.After(d):
142 case <-ctx.Done():
143 return ctx.Err()
144 }
145 }
146 }
147 return last
148}
149
150func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error {
151 url, token := ra.TarballURL, ra.Token
152 // The token goes only to the origin the host named, never by the URL alone.
153 if !tokenAllowed(ra) {
154 token = ""
155 }
156 ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
157 defer cancel()
158 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
159 if err != nil {
160 return err
161 }
162 if token != "" {
163 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
164 }
165 req.Header.Set("User-Agent", "ermite-runner")
166 resp, err := j.archiveClient().Do(req)
167 if err != nil {
168 return err
169 }
170 defer func() { _ = resp.Body.Close() }()
171 switch {
172 case resp.StatusCode == http.StatusNotFound:
173 return errActionNotFound{url}
174 case resp.StatusCode == http.StatusForbidden:
175 return errAccessDenied{url}
176 case resp.StatusCode < 200 || resp.StatusCode > 299:
177 // HttpResponseMessage.EnsureSuccessStatusCode.
178 return ghErrorf("Response status code does not indicate success: %d (%s).", resp.StatusCode, http.StatusText(resp.StatusCode))
179 }
180 f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
181 if err != nil {
182 return err
183 }
184 n, err := io.Copy(f, io.LimitReader(resp.Body, maxDownloadBytes+1))
185 if cerr := f.Close(); err == nil {
186 err = cerr
187 }
188 if err == nil && n > maxDownloadBytes {
189 err = errors.New("the archive is larger than 1 GiB")
190 }
191 return err
192}
193
194// watermark is the file that says an action was downloaded
195// (ActionManager.GetWatermarkFilePath).
196func watermark(dir string) string { return dir + ".completed" }
197
198// downloadAction downloads and unpacks one action into dir
199// (ActionManager.DownloadRepositoryActionAsync): in a temporary directory, the
200// archive's single top directory stripped, renamed to dir on success, and
201// marked with the watermark.
202func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, name, ref, dir string) error {
203 if _, err := os.Stat(watermark(dir)); err == nil {
204 j.debug(num, fmt.Sprintf("Action '%s@%s' already downloaded at '%s'.", name, ref, dir))
205 return nil
206 }
207 j.out(num, fmt.Sprintf("Download action repository '%s@%s' (SHA:%s)", name, ref, ra.ResolvedSHA))
208
209 actions := j.state.Layout().Actions
210 tmp := filepath.Join(actions, "_temp_"+uuid.NewString())
211 if err := os.MkdirAll(tmp, 0o755); err != nil {
212 return err
213 }
214 defer func() { _ = os.RemoveAll(tmp) }()
215 archive := filepath.Join(tmp, uuid.NewString()+".tar.gz")
216 if err := j.downloadArchive(ctx, num, ra, archive); err != nil {
217 return err
218 }
219 staging := filepath.Join(tmp, "_staging")
220 if err := os.Mkdir(staging, 0o755); err != nil {
221 return err
222 }
223 af, err := os.Open(archive)
224 if err != nil {
225 return err
226 }
227 var owner *[2]int
228 if c := j.cfg.Credential; c != nil {
229 owner = &[2]int{int(c.Uid), int(c.Gid)}
230 }
231 // The top directory is stripped while extracting, so that links are checked
232 // against the tree they will live in (see extractTarball).
233 err = extractTarball(ctx, af, staging, owner)
234 _ = af.Close()
235 if err != nil {
236 return ghErrorf("Can't extract the archive of action %s@%s: %v", name, ref, err)
237 }
238 _ = os.RemoveAll(dir)
239 if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
240 return err
241 }
242 if err := os.Rename(staging, dir); err != nil {
243 return err
244 }
245 j.debug(num, fmt.Sprintf("Unwrap the archive to '%s'", dir))
246 if err := os.WriteFile(watermark(dir), []byte(j.now().UTC().Format(time.DateTime)), 0o644); err != nil {
247 return err
248 }
249 j.own(dir)
250 _ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, err error) error {
251 if err == nil {
252 j.own(p)
253 }
254 return nil
255 })
256 return nil
257}