1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "strings"
10 "unicode/utf8"
11
12 "connectrpc.com/connect"
13
14 ermite "bnema.dev/ermite-api/gen/ermite"
15
16 "bnema.dev/ermite-runner/internal/execstream/execpb"
17)
18
19// Limits of what the sandbox may ask the server through the host. The sandbox
20// runs untrusted code, so the host does not trust its requests.
21//
22// Resolutions are cached for the job, by "uses" value: an action that was
23// resolved once is never asked again (the same sha for the whole job is what the
24// server promises anyway), so a local composite action used 500 times costs one
25// resolution. The budget counts only what reaches the server:
26//
27// - a request from the sandbox has at most maxRequestRefs references (a
28// workflow has at most 1,000 steps), split into calls of maxResolveRefs, the
29// limit of ResolveActionsRequest;
30// - a job makes at most maxResolveRounds calls (up to 10 levels of repository
31// composites, then one call per local composite that brings new actions) and
32// asks for at most maxResolveRefsTotal distinct references.
33//
34// These are explicit limits of ermite, not of GitHub: a job that needs more
35// distinct actions than that fails "Set up job" with the message below.
36const (
37 maxRequestRefs = 1000
38 maxResolveRefs = 100 // per call to the server: ResolveActionsRequest.refs
39 maxResolveRounds = 128 // calls per job
40 maxResolveRefsTotal = 2048 // distinct references per job
41 maxRefLen = 1024 // the whole "uses" value
42 maxOwnerRepoLen = 100 // owner and repository names
43 maxPathElemLen = 255 // an element of the path of the action: a file name
44 maxRefNameLen = 255
45)
46
47// checkUses validates a "uses" value of a request: owner/repo[/path]@ref. The
48// owner and the repository have the characters of repository names; the path of
49// the action is a path in the repository (any characters but control ones, with
50// no empty, "." or ".." element and no backslash, which the runner reads as a
51// separator, action.ParseUses); the ref has the characters git allows. The
52// server decides whether the action exists; this keeps garbage and oversized
53// strings from reaching it.
54func checkUses(u string) error {
55 bad := func() error { return fmt.Errorf("%q is not a valid action reference", cut(u)) }
56 if u == "" || len(u) > maxRefLen || !utf8.ValidString(u) {
57 return bad()
58 }
59 name, ref, ok := strings.Cut(u, "@")
60 if !ok || ref == "" || len(ref) > maxRefNameLen || strings.ContainsFunc(ref, badRefRune) {
61 return bad()
62 }
63 segs := strings.Split(name, "/")
64 if len(segs) < 2 {
65 return bad()
66 }
67 for _, seg := range segs[:2] {
68 if seg == "" || seg == "." || seg == ".." || len(seg) > maxOwnerRepoLen || strings.ContainsFunc(seg, badSegmentRune) {
69 return bad()
70 }
71 }
72 for _, seg := range segs[2:] {
73 if seg == "" || seg == "." || seg == ".." || len(seg) > maxPathElemLen || strings.ContainsFunc(seg, badPathRune) {
74 return bad()
75 }
76 }
77 return nil
78}
79
80// badPathRune: control characters (NUL included) and the backslash. Invalid
81// UTF-8 is refused for the whole value by utf8.ValidString; a literal U+FFFD is a
82// valid character.
83func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' }
84
85func cut(s string) string {
86 if len(s) > 80 {
87 return s[:80] + "..."
88 }
89 return s
90}
91
92func badSegmentRune(r rune) bool {
93 return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' && r != '_' && r != '.'
94}
95
96// badRefRune: a ref is a branch, a tag or a sha; no control characters, spaces
97// or characters git refuses.
98func badRefRune(r rune) bool {
99 return r <= ' ' || r == 0x7f || strings.ContainsRune("~^:?*[\\@", r) // "@": a "uses" has exactly one (action.ParseUses)
100}
101
102// resolveActions answers a ResolveRequest of the executor with
103// RunnerService.ResolveActions. The job token stays here: the executor only
104// gets what the server returns (the per-archive tokens included, which it
105// masks). Requests come one at a time (see execstream.JobHandler.Resolve). What
106// was resolved before in the job comes from the cache; the rest goes to the
107// server in calls of at most maxResolveRefs, under the budget above. A call that
108// fails without a verdict is retried like the other job calls; a failure of the
109// call itself is reported to the executor, which fails "Set up job"
110// (ActionManager: FailedToResolveActionDownloadInfoException). ctx ends with the
111// session.
112func (j *jobRun) resolveActions(ctx context.Context, req *execpb.ResolveRequest) *execpb.ResolveResponse {
113 fail := func(format string, a ...any) *execpb.ResolveResponse {
114 return &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)}
115 }
116 if len(req.Uses) == 0 {
117 return fail("an empty request for action resolution")
118 }
119 if len(req.Uses) > maxRequestRefs {
120 return fail("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxRequestRefs)
121 }
122 for _, u := range req.Uses {
123 if err := checkUses(u); err != nil {
124 return fail("%v", err)
125 }
126 }
127 // The job ending, or the session, stops the calls.
128 ctx, cancel := context.WithCancel(ctx)
129 defer cancel()
130 stop := context.AfterFunc(j.ctx, cancel)
131 defer stop()
132
133 got := map[string]*execpb.ResolvedAction{}
134 var pending []string
135 j.mu.Lock()
136 for _, u := range req.Uses {
137 if _, dup := got[u]; dup {
138 continue
139 }
140 if c, ok := j.resolved[u]; ok {
141 got[u] = c
142 continue
143 }
144 got[u] = nil
145 pending = append(pending, u)
146 }
147 j.mu.Unlock()
148 for len(pending) > 0 {
149 n := min(len(pending), maxResolveRefs)
150 chunk := pending[:n]
151 pending = pending[n:]
152 j.mu.Lock()
153 j.resolveRounds++
154 j.resolveRefs += len(chunk)
155 over := j.resolveRounds > maxResolveRounds || j.resolveRefs > maxResolveRefsTotal
156 j.mu.Unlock()
157 if over {
158 return fail("this job asked to resolve too many actions (the limits are %d requests and %d distinct references)", maxResolveRounds, maxResolveRefsTotal)
159 }
160 res, errResp := j.callResolve(ctx, chunk)
161 if errResp != nil {
162 return errResp
163 }
164 j.mu.Lock()
165 for i, u := range chunk {
166 got[u] = res[i]
167 if res[i].Error == "" { // an error is not cached: it may be a passing one
168 if j.resolved == nil {
169 j.resolved = map[string]*execpb.ResolvedAction{}
170 }
171 j.resolved[u] = res[i]
172 }
173 }
174 j.mu.Unlock()
175 }
176 out := &execpb.ResolveResponse{}
177 for _, u := range req.Uses {
178 out.Actions = append(out.Actions, got[u])
179 }
180 return out
181}
182
183// callResolve makes one call to the server, with retries, and converts the
184// answer; the second result is the error to send to the executor.
185func (j *jobRun) callResolve(ctx context.Context, uses []string) ([]*execpb.ResolvedAction, *execpb.ResolveResponse) {
186 fail := func(format string, a ...any) ([]*execpb.ResolvedAction, *execpb.ResolveResponse) {
187 return nil, &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)}
188 }
189 refs := make([]*ermite.ActionRef, len(uses))
190 for i, u := range uses {
191 refs[i] = &ermite.ActionRef{Uses: u}
192 }
193 b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax}
194 for attempt := 1; ; attempt++ {
195 cctx, ccancel := context.WithTimeout(ctx, j.t.CallTimeout)
196 resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs}))
197 ccancel()
198 if err == nil {
199 conv := j.resolveResponse(resp.Msg, len(uses))
200 if conv.Error == "" {
201 j.cacheArchives(ctx, resp.Msg.Actions, conv.Actions)
202 }
203 if conv.Error != "" {
204 return fail("%s", conv.Error)
205 }
206 return conv.Actions, nil
207 }
208 if ctx.Err() != nil {
209 return fail("the job was cancelled while its actions were resolved")
210 }
211 switch classify(err) {
212 case kindEnded, kindAbandoned:
213 j.abandon(err)
214 return fail("the server ended the job")
215 case kindPermanent:
216 return fail("resolving actions failed: %s", connect.CodeOf(err))
217 }
218 j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed")
219 if attempt >= 3 || !sleep(ctx, b.next()) {
220 return fail("resolving actions failed: %s", connect.CodeOf(err))
221 }
222 }
223}
224
225// resolveResponse converts the answer of the server. The token of an archive is
226// a secret that the sandbox sends in an Authorization header, so it only goes
227// with an archive of the ermite source whose tarball URL is on an origin this
228// host knows to be ermite: the public origin of the job (github.server_url) or
229// the origin of the runner's own URL. The origin the sandbox must send it to is
230// AuthOrigin; with guest forge access the tarball URL on the public origin is
231// rewritten to the runner's, like the URLs of the contexts. A token that does
232// not satisfy this is dropped (the download then fails like an unauthorised
233// one).
234func (j *jobRun) resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse {
235 if len(m.Actions) != want {
236 return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)}
237 }
238 out := &execpb.ResolveResponse{}
239 for _, a := range m.Actions {
240 ra := &execpb.ResolvedAction{
241 Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha,
242 TarballUrl: a.TarballUrl, Source: a.Source.String(), Error: a.Error,
243 }
244 if a.Source == ermite.ActionSource_ACTION_SOURCE_ERMITE {
245 if origin := originOf(a.TarballUrl); origin != "" {
246 if j.guestForge != "" && origin == j.publicOrigin {
247 ra.TarballUrl = j.guestForge + strings.TrimPrefix(a.TarballUrl, a.TarballUrl[:strings.Index(a.TarballUrl, "://")+3+hostLen(a.TarballUrl)])
248 origin = j.guestForge
249 }
250 if a.Token != "" && (origin == j.guestForge || origin == j.publicOrigin || origin == j.serverOrigin) {
251 ra.Token, ra.AuthOrigin = a.Token, origin
252 }
253 }
254 }
255 out.Actions = append(out.Actions, ra)
256 }
257 return out
258}
259
260// setOrigins records the origins that are ermite for this job: the public one
261// (the origin of github.server_url as the server sent it), the runner's own
262// (ERMITE_RUNNER_URL) and the guest forge origin, "" when it is off.
263func (j *jobRun) setOrigins(contexts, serverURL, guestForge string) {
264 j.serverOrigin = originOf(serverURL)
265 j.guestForge = guestForge
266 if guestForge != "" {
267 j.guestForge = originOf(guestForge)
268 }
269 if top, err := parseObject([]byte(contexts)); err == nil {
270 for _, m := range top {
271 if m.key != "github" {
272 continue
273 }
274 gh, err := parseObject(m.val)
275 if err != nil {
276 break
277 }
278 for _, g := range gh {
279 var s string
280 if g.key == "server_url" && json.Unmarshal(g.val, &s) == nil {
281 j.publicOrigin = originOf(s)
282 }
283 }
284 }
285 }
286}
287
288// SetGitHubToken returns the contexts JSON of a job with github.token set to
289// token (the job repository token, Job.github_token). The key is added first,
290// like the runner does (ExecutionContext.cs:1023: token, then what the server
291// gave); an empty token leaves the contexts as they are.
292func SetGitHubToken(contexts, token string) (string, error) {
293 if token == "" {
294 return contexts, nil
295 }
296 top, err := parseObject([]byte(contexts))
297 if err != nil {
298 return "", fmt.Errorf("job contexts: %w", err)
299 }
300 gi := -1
301 for i, m := range top {
302 if m.key == "github" {
303 gi = i
304 }
305 }
306 var gh []jsonMember
307 if gi >= 0 {
308 if gh, err = parseObject(top[gi].val); err != nil {
309 return "", fmt.Errorf("job contexts: github: %w", err)
310 }
311 }
312 tv, err := json.Marshal(token)
313 if err != nil {
314 return "", err
315 }
316 set := false
317 for i, m := range gh {
318 if m.key == "token" {
319 gh[i].val, set = tv, true
320 }
321 }
322 if !set {
323 gh = append([]jsonMember{{"token", tv}}, gh...)
324 }
325 ghv, err := encodeObject(gh)
326 if err != nil {
327 return "", err
328 }
329 if gi >= 0 {
330 top[gi].val = ghv
331 } else {
332 top = append(top, jsonMember{"github", ghv})
333 }
334 out, err := encodeObject(top)
335 return string(out), err
336}
337
338// hostLen is the length of the authority of an absolute URL.
339func hostLen(u string) int {
340 rest := u[strings.Index(u, "://")+3:]
341 if i := strings.IndexAny(rest, "/?#"); i >= 0 {
342 return i
343 }
344 return len(rest)
345}