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