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 "time"
16
17 "github.com/google/uuid"
18
19 "bnema.dev/ermite-runner/internal/actionscache"
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
61// (actionscache.NewClient): it follows redirects but never carries credentials
62// across an origin. Tests may supply their own transport through
63// Actions.HTTPClient; its redirect policy is replaced.
64func (j *job) archiveClient() *http.Client {
65 return actionscache.NewClient(j.cfg.Actions.HTTPClient)
66}
67
68// sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper.
69// GetRandomBackoff), or what the test hook says.
70func (j *job) sleepBackoff(ctx context.Context) time.Duration {
71 if d := j.cfg.Actions.Backoff; d != nil {
72 return d()
73 }
74 return time.Duration(10+rand.Int64N(21)) * time.Second
75}
76
77// downloadArchive saves the archive of an action in file, retrying like the
78// runner: three attempts, a warning after each failure, a 404 or a 403 at once.
79// The final error is the one of the last attempt, which is what the runner
80// shows (PrepareActionsAsync prints the inner exception of
81// FailedToDownloadActionException).
82func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, file string) error {
83 url := ra.TarballURL
84 var last error
85 for attempt := 1; attempt <= downloadAttempts; attempt++ {
86 err := j.fetch(ctx, ra, file)
87 if err == nil {
88 return nil
89 }
90 last = err
91 var nf errActionNotFound
92 var ad errAccessDenied
93 if ctx.Err() != nil || errors.As(err, &ad) {
94 return err
95 }
96 if errors.As(err, &nf) {
97 return err
98 }
99 if attempt == downloadAttempts {
100 break
101 }
102 j.tagged(num, "##[warning]", fmt.Sprintf("Failed to download action '%s'. Error: %s ", url, err.Error()))
103 if os.Getenv(noBackoffEnv) == "" {
104 d := j.sleepBackoff(ctx)
105 j.tagged(num, "##[warning]", fmt.Sprintf("Back off %v seconds before retry.", d.Seconds()))
106 select {
107 case <-time.After(d):
108 case <-ctx.Done():
109 return ctx.Err()
110 }
111 }
112 }
113 return last
114}
115
116func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error {
117 url, token := ra.TarballURL, ra.Token
118 // The token goes only to the origin the host named, never by the URL alone.
119 if !tokenAllowed(ra) {
120 token = ""
121 }
122 ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
123 defer cancel()
124 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
125 if err != nil {
126 return err
127 }
128 if token != "" {
129 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
130 }
131 req.Header.Set("User-Agent", "ermite-runner")
132 resp, err := j.archiveClient().Do(req)
133 if err != nil {
134 return err
135 }
136 defer func() { _ = resp.Body.Close() }()
137 switch {
138 case resp.StatusCode == http.StatusNotFound:
139 return errActionNotFound{url}
140 case resp.StatusCode == http.StatusForbidden:
141 return errAccessDenied{url}
142 case resp.StatusCode < 200 || resp.StatusCode > 299:
143 // HttpResponseMessage.EnsureSuccessStatusCode.
144 return ghErrorf("Response status code does not indicate success: %d (%s).", resp.StatusCode, http.StatusText(resp.StatusCode))
145 }
146 f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
147 if err != nil {
148 return err
149 }
150 n, err := io.Copy(f, io.LimitReader(resp.Body, maxDownloadBytes+1))
151 if cerr := f.Close(); err == nil {
152 err = cerr
153 }
154 if err == nil && n > maxDownloadBytes {
155 err = errors.New("the archive is larger than 1 GiB")
156 }
157 return err
158}
159
160// watermark is the file that says an action was downloaded
161// (ActionManager.GetWatermarkFilePath).
162func watermark(dir string) string { return dir + ".completed" }
163
164// downloadAction downloads and unpacks one action into dir
165// (ActionManager.DownloadRepositoryActionAsync): in a temporary directory, the
166// archive's single top directory stripped, renamed to dir on success, and
167// marked with the watermark.
168func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, name, ref, dir string) error {
169 if _, err := os.Stat(watermark(dir)); err == nil {
170 j.debug(num, fmt.Sprintf("Action '%s@%s' already downloaded at '%s'.", name, ref, dir))
171 return nil
172 }
173 j.out(num, fmt.Sprintf("Download action repository '%s@%s' (SHA:%s)", name, ref, ra.ResolvedSHA))
174
175 actions := j.state.Layout().Actions
176 tmp := filepath.Join(actions, "_temp_"+uuid.NewString())
177 if err := os.MkdirAll(tmp, 0o755); err != nil {
178 return err
179 }
180 defer func() { _ = os.RemoveAll(tmp) }()
181 archive := filepath.Join(tmp, uuid.NewString()+".tar.gz")
182 if err := j.downloadArchive(ctx, num, ra, archive); err != nil {
183 return err
184 }
185 staging := filepath.Join(tmp, "_staging")
186 if err := os.Mkdir(staging, 0o755); err != nil {
187 return err
188 }
189 af, err := os.Open(archive)
190 if err != nil {
191 return err
192 }
193 var owner *[2]int
194 if c := j.cfg.Credential; c != nil {
195 owner = &[2]int{int(c.Uid), int(c.Gid)}
196 }
197 // The top directory is stripped while extracting, so that links are checked
198 // against the tree they will live in (see extractTarball).
199 err = extractTarball(ctx, af, staging, owner)
200 _ = af.Close()
201 if err != nil {
202 return ghErrorf("Can't extract the archive of action %s@%s: %v", name, ref, err)
203 }
204 _ = os.RemoveAll(dir)
205 if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
206 return err
207 }
208 if err := os.Rename(staging, dir); err != nil {
209 return err
210 }
211 j.debug(num, fmt.Sprintf("Unwrap the archive to '%s'", dir))
212 if err := os.WriteFile(watermark(dir), []byte(j.now().UTC().Format(time.DateTime)), 0o644); err != nil {
213 return err
214 }
215 j.own(dir)
216 _ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, err error) error {
217 if err == nil {
218 j.own(p)
219 }
220 return nil
221 })
222 return nil
223}