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