1// SPDX-License-Identifier: MIT
2
3// Package executor runs the steps of a GitHub Actions job inside a sandbox.
4// It is a port of the parts of the official runner (actions/runner, C#, MIT)
5// that CI-1 needs: file commands, shells, contexts and the steps runner. The
6// package does no sandboxing and no networking: it runs local processes and
7// reports to a Reporter.
8package executor
9
10import (
11 "bytes"
12 "errors"
13 "fmt"
14 "io"
15 "os"
16 "path/filepath"
17 "slices"
18 "strings"
19 "syscall"
20 "unicode/utf8"
21
22 "github.com/google/uuid"
23)
24
25// Names of the file commands (the GitHub context names) and their
26// environment variables.
27const (
28 cmdPath = "path"
29 cmdEnv = "env"
30 cmdStepSummary = "step_summary"
31 cmdState = "state"
32 cmdOutput = "output"
33 // The two artifact files exist on GitHub-hosted runners (recordings: the
34 // github context has artifacts and artifacts_list, the steps GITHUB_ARTIFACTS
35 // and GITHUB_ARTIFACTS_LIST). ermite does not process them, see below.
36 cmdArtifacts = "artifacts"
37 cmdArtifactsList = "artifacts_list"
38)
39
40// fileCommand describes one file command like the IFileCommandExtension of
41// FileCommandManager.cs.
42type fileCommand struct {
43 ContextName string // github context name; the variable is GITHUB_<UPPER>
44 FilePrefix string
45}
46
47// fileCommands is in the order of the official runner's extension list, which
48// is the order ProcessFiles runs them.
49var fileCommands = []fileCommand{
50 {cmdPath, "add_path_"}, // AddPathFileCommand (FileCommandManager.cs:127)
51 {cmdEnv, "set_env_"}, // SetEnvFileCommand (:160)
52 {cmdStepSummary, "step_summary_"}, // CreateStepSummaryCommand (:221)
53 {cmdState, "save_state_"}, // SaveStateFileCommand (:295)
54 {cmdOutput, "set_output_"}, // SetOutputFileCommand (:328)
55 // CreateArtifactsFileCommand (CreateArtifactsFileCommand.cs:39) and
56 // ArtifactsListFileCommand (ArtifactsListFileCommand.cs:37), registered in
57 // ExtensionManager.cs:65-66. Both do nothing unless the feature flag
58 // actions_runner_allow_artifacts_file or ACTIONS_RUNNER_ALLOW_ARTIFACTS_FILE
59 // is on (CreateArtifactsFileCommand.cs:67-79); ermite has neither, so the
60 // files are created empty (as the C# runner does when the feature is off)
61 // and what a step writes to them is ignored. Artifact subjects are not
62 // declared yet.
63 {cmdArtifacts, "artifacts_"},
64 {cmdArtifactsList, "artifacts_list_"},
65}
66
67// EnvVarName returns the environment variable that holds the file of a command:
68// GITHUB_ARTIFACTS, GITHUB_ARTIFACTS_LIST, GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH,
69// GITHUB_STATE, GITHUB_STEP_SUMMARY
70// (GitHubContext.GetRuntimeEnvironmentVariables, GitHubContext.cs:52).
71func envVarName(contextName string) string { return "GITHUB_" + strings.ToUpper(contextName) }
72
73// stepSummaryLimit is CreateStepSummaryCommand.AttachmentSizeLimit
74// (FileCommandManager.cs:223).
75const stepSummaryLimit = 1024 * 1024
76
77// setEnvBlockList is SetEnvFileCommand._setEnvBlockList (FileCommandManager.cs:215).
78var setEnvBlockList = []string{"NODE_OPTIONS"}
79
80// msgError is an error whose text is GitHub's own message, capitalised and
81// punctuated like the official runner's.
82type msgError string
83
84func (e msgError) Error() string { return string(e) }
85
86func ghErrorf(format string, a ...any) error { return msgError(fmt.Sprintf(format, a...)) }
87
88// Pair is one name and value read from an environment-style file.
89type Pair struct{ Key, Value string }
90
91// Sink receives the effects of the file commands of a step.
92type Sink interface {
93 // SetEnv sets an environment variable of the job (GITHUB_ENV).
94 SetEnv(name, value string)
95 // SetOutput sets an output of the step (GITHUB_OUTPUT).
96 SetOutput(name, value string)
97 // AddPath prepends a directory to PATH for the next steps (GITHUB_PATH). The
98 // caller keeps the list like Global.PrependPath: the last added is first
99 // in PATH, and a repeated entry moves to the end of the list.
100 AddPath(path string)
101 // SaveState keeps a value for the step's own post phase (GITHUB_STATE).
102 SaveState(name, value string)
103 // Error writes an error issue to the log (##[error]).
104 Error(msg string)
105 // Debug writes a ##[debug] line when step debugging is on.
106 Debug(msg string)
107 // CommandFailed marks the step as failed (context.CommandResult = Failed).
108 CommandFailed()
109}
110
111// FileCommands manages the per-step files, like FileCommandManager.
112//
113// The directory belongs to the user of the steps, and the executor reads the
114// files as root after each step, so every access goes through an os.Root opened
115// before the first step ran: replacing the directory or a file by a symbolic
116// link cannot make the executor read or write anything outside it. Files are
117// opened with O_NOFOLLOW and must be regular files.
118type FileCommands struct {
119 dir string
120 root *os.Root
121 suffix string
122 // owner, when set, is the user the files are given to.
123 owner *[2]int
124}
125
126// NewFileCommands uses dir (RUNNER_TEMP/_runner_file_commands), creating it.
127// Call Close when the job is over.
128func NewFileCommands(dir string) (*FileCommands, error) {
129 if err := os.MkdirAll(dir, 0o755); err != nil {
130 return nil, err
131 }
132 root, err := os.OpenRoot(dir)
133 if err != nil {
134 return nil, err
135 }
136 return &FileCommands{dir: dir, root: root}, nil
137}
138
139// Close releases the directory.
140func (f *FileCommands) Close() error { return f.root.Close() }
141
142// SetOwner gives the files of the next steps to uid:gid.
143func (f *FileCommands) SetOwner(uid, gid int) { f.owner = &[2]int{uid, gid} }
144
145func (f *FileCommands) name(c fileCommand, suffix string) string { return c.FilePrefix + suffix }
146
147func (f *FileCommands) path(c fileCommand, suffix string) string {
148 return filepath.Join(f.dir, f.name(c, suffix))
149}
150
151// Init creates fresh, empty files for the next step and returns the
152// environment variables that name them (InitializeFiles,
153// FileCommandManager.cs:42). The files of the previous step are kept: the
154// runner makes a new FileCommandManager for every step (ActionRunner.cs:171),
155// whose suffix starts empty, so it never deletes an old file (recorded:
156// sum-env, "previous file still exists: yes").
157func (f *FileCommands) Init() (map[string]string, error) {
158 f.suffix = uuid.NewString()
159 env := make(map[string]string, len(fileCommands))
160 for _, c := range fileCommands {
161 if err := createExcl(f.root, f.name(c, f.suffix), nil, 0o644, f.owner); err != nil {
162 return nil, err
163 }
164 env[envVarName(c.ContextName)] = f.path(c, f.suffix)
165 }
166 return env, nil
167}
168
169// Process reads the files of the step that just ran and applies them to sink.
170// A command that fails does not stop the others (ProcessFiles, :75).
171func (f *FileCommands) Process(sink Sink) {
172 for _, c := range fileCommands {
173 p := f.name(c, f.suffix)
174 var err error
175 switch c.ContextName {
176 case cmdPath:
177 err = processAddPath(sink, f.root, p)
178 case cmdEnv:
179 err = processSetEnv(sink, f.root, p)
180 case cmdStepSummary:
181 err = processStepSummary(sink, f.root, p)
182 case cmdState:
183 err = processPairs(sink, f.root, p, sink.SaveState, func(k, v string) { sink.Debug(fmt.Sprintf("Save intra-action state %s = %s", k, v)) })
184 case cmdArtifacts, cmdArtifactsList:
185 // Disabled feature: nothing to process (see fileCommands).
186 case cmdOutput:
187 err = processPairs(sink, f.root, p, sink.SetOutput, func(k, v string) { sink.Debug(fmt.Sprintf("Set output %s = %s", k, v)) })
188 }
189 if err != nil {
190 sink.Error(fmt.Sprintf("Unable to process file command '%s' successfully.", c.ContextName))
191 sink.Error(err.Error())
192 sink.CommandFailed()
193 }
194 }
195}
196
197// errNotRegular is the error of a file command file that was replaced by
198// something else (a symbolic link, a FIFO, a directory).
199var errNotRegular = ghErrorf("The file is not a regular file")
200
201// openRegular opens name in root for reading without following a symbolic link
202// and without blocking on a FIFO, and checks that it is a regular file.
203func openRegular(root *os.Root, name string) (*os.File, error) {
204 fh, err := root.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
205 if err != nil {
206 if errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.EMLINK) {
207 return nil, errNotRegular
208 }
209 return nil, err
210 }
211 st, err := fh.Stat()
212 if err != nil {
213 _ = fh.Close()
214 return nil, err
215 }
216 if !st.Mode().IsRegular() {
217 _ = fh.Close()
218 return nil, errNotRegular
219 }
220 return fh, nil
221}
222
223// readText is File.ReadAllText: a UTF-8 BOM is dropped and invalid bytes
224// become U+FFFD. A missing file gives ok=false and no error (the callers
225// debug-log it; AddPath and StepSummary skip silently); a file that is not a
226// regular file is an error.
227func readText(root *os.Root, name string) (text string, ok bool, err error) {
228 fh, err := openRegular(root, name)
229 if errors.Is(err, os.ErrNotExist) {
230 return "", false, nil
231 }
232 if err != nil {
233 return "", false, err
234 }
235 defer func() { _ = fh.Close() }()
236 b, err := io.ReadAll(fh)
237 if err != nil {
238 return "", false, err
239 }
240 return decodeText(b), true, nil
241}
242
243// decodeText is the decoding of File.ReadAllText: a UTF-8 BOM is dropped and
244// invalid bytes become U+FFFD.
245func decodeText(b []byte) string {
246 b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf"))
247 if !utf8.Valid(b) {
248 b = bytes.ToValidUTF8(b, []byte("\ufffd"))
249 }
250 return string(b)
251}
252
253// createExcl creates name in root with the content, failing if it exists (an
254// existing entry, a symbolic link included, is removed first, never followed),
255// and gives it to owner when set. The creation uses O_EXCL|O_NOFOLLOW, so
256// something planted between the removal and the creation makes it fail. The
257// owner is set on the open file, not by name: the user of the steps owns the
258// directory and could swap the name for something else between the close and a
259// chown by name.
260func createExcl(root *os.Root, name string, content []byte, perm os.FileMode, owner *[2]int) error {
261 if err := root.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) {
262 return err
263 }
264 fh, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, perm)
265 if err != nil {
266 return err
267 }
268 if _, err := fh.Write(content); err != nil {
269 _ = fh.Close()
270 return err
271 }
272 if owner != nil {
273 if err := fh.Chown(owner[0], owner[1]); err != nil {
274 _ = fh.Close()
275 return err
276 }
277 }
278 return fh.Close()
279}
280
281// processAddPath is AddPathFileCommand.ProcessCommand (FileCommandManager.cs:134).
282func processAddPath(sink Sink, root *os.Root, name string) error {
283 text, ok, err := readText(root, name)
284 if err != nil || !ok {
285 return err
286 }
287 // File.ReadAllLines splits on \r\n, \n and \r.
288 for _, line := range splitLines(text) {
289 if line == "" {
290 continue
291 }
292 sink.AddPath(line)
293 }
294 return nil
295}
296
297// splitLines is File.ReadAllLines / StreamReader.ReadLine: \r\n, \r and \n
298// end a line; no empty last line is produced.
299func splitLines(s string) []string {
300 var out []string
301 for len(s) > 0 {
302 i := strings.IndexAny(s, "\r\n")
303 if i < 0 {
304 out = append(out, s)
305 break
306 }
307 out = append(out, s[:i])
308 if s[i] == '\r' && i+1 < len(s) && s[i+1] == '\n' {
309 i++
310 }
311 s = s[i+1:]
312 }
313 return out
314}
315
316// processSetEnv is SetEnvFileCommand.ProcessCommand (:167). Pairs read before
317// a format error are applied, like the lazy enumeration of the original.
318func processSetEnv(sink Sink, root *os.Root, name string) error {
319 pairs, err := readEnvFile(sink, root, name)
320 for _, p := range pairs {
321 if blocked := blockedEnvName(p.Key); blocked != "" {
322 sink.Error(fmt.Sprintf("Can't store %s output parameter using '$GITHUB_ENV' command.", blocked))
323 continue
324 }
325 sink.SetEnv(p.Key, p.Value)
326 sink.Debug(fmt.Sprintf("%s='%s'", p.Key, p.Value))
327 }
328 return err
329}
330
331func blockedEnvName(name string) string {
332 for _, b := range setEnvBlockList {
333 if strings.EqualFold(b, name) {
334 return b
335 }
336 }
337 return ""
338}
339
340// processPairs serves GITHUB_STATE and GITHUB_OUTPUT.
341func processPairs(sink Sink, root *os.Root, name string, apply func(k, v string), debug func(k, v string)) error {
342 pairs, err := readEnvFile(sink, root, name)
343 for _, p := range pairs {
344 apply(p.Key, p.Value)
345 debug(p.Key, p.Value)
346 }
347 return err
348}
349
350func readEnvFile(sink Sink, root *os.Root, name string) ([]Pair, error) {
351 text, ok, err := readText(root, name)
352 if err != nil {
353 return nil, err
354 }
355 if !ok {
356 sink.Debug(fmt.Sprintf("File does not exist '%s'", filepath.Join(root.Name(), name)))
357 return nil, nil
358 }
359 return ParseEnvFile(text)
360}
361
362// afterSummaryStat is called between the size check of the summary and its
363// read; tests use it to make the file grow.
364var afterSummaryStat func()
365
366// processStepSummary is CreateStepSummaryCommand.ProcessCommand
367// (FileCommandManager.cs:230-275): a missing or empty file is ignored, a file
368// above 1 MiB is an error; otherwise the lines are masked one by one and the
369// result goes to the sink when it keeps summaries. mask may be nil.
370func processStepSummary(sink Sink, root *os.Root, name string) error {
371 fh, err := openRegular(root, name)
372 if errors.Is(err, os.ErrNotExist) {
373 return nil
374 }
375 if err != nil {
376 return err
377 }
378 defer func() { _ = fh.Close() }()
379 st, err := fh.Stat()
380 if err != nil || st.Size() == 0 {
381 return nil
382 }
383 tooLarge := func(size int64) {
384 sink.Error(fmt.Sprintf("$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of %dk, got %dk. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary", stepSummaryLimit/1024, size/1024))
385 }
386 if st.Size() > stepSummaryLimit {
387 tooLarge(st.Size())
388 return nil
389 }
390 ss, ok := sink.(summarySink)
391 if !ok {
392 return nil
393 }
394 if afterSummaryStat != nil {
395 afterSummaryStat()
396 }
397 // The file belongs to the user of the step, who can still write to it (a
398 // background process of the step): what is read is bounded by the limit on
399 // the descriptor that was checked, not by the size seen a moment ago.
400 b, err := io.ReadAll(io.LimitReader(fh, stepSummaryLimit+1))
401 if err != nil {
402 return err
403 }
404 if len(b) > stepSummaryLimit {
405 tooLarge(int64(len(b)))
406 return nil
407 }
408 text := decodeText(b)
409 var mask func(string) string
410 if m, has := sink.(interface{ MaskText(string) string }); has {
411 mask = m.MaskText
412 } else {
413 mask = func(s string) string { return s }
414 }
415 content := summaryContent(text, mask)
416 // The size test of the runner is on the file (above). A file of exactly
417 // 1 MiB without a final line feed gets one from WriteLine and would pass the
418 // limit of the contract by that byte (recorded: sum-too-large, "exactly
419 // 1 MiB" uploads on GitHub): drop that line feed.
420 if len(content) == stepSummaryLimit+1 && st.Size() <= stepSummaryLimit && !strings.HasSuffix(text, "\n") {
421 content = content[:stepSummaryLimit]
422 }
423 if len(content) > stepSummaryLimit {
424 // Masking made it longer than the limit of the contract.
425 tooLarge(int64(len(content)))
426 return nil
427 }
428 ss.Summary(content)
429 return nil
430}
431
432// ParseEnvFile parses the contents of GITHUB_ENV, GITHUB_OUTPUT or
433// GITHUB_STATE: EnvFileKeyValuePairs.GetEnumerator (FileCommandManager.cs:364).
434//
435// Lines are NAME=VALUE (split at the first "=") or heredocs NAME<<DELIMITER up
436// to a line equal to DELIMITER; the form whose marker comes first wins. Empty
437// lines between entries are skipped. Only "\n" ends a line (on Linux). The
438// pairs read before an error are returned with it, because the original
439// applies them as it enumerates.
440func ParseEnvFile(text string) ([]Pair, error) {
441 var out []Pair
442 index := 0
443 line, _, ok := readLine(text, &index)
444 for ok {
445 if line != "" {
446 var key, output string
447 equals := strings.Index(line, "=")
448 heredoc := strings.Index(line, "<<")
449 switch {
450 case equals >= 0 && (heredoc < 0 || equals < heredoc):
451 // The original tests string.IsNullOrEmpty(line) here, which cannot
452 // be true; the intent (the message says so) is an empty name, and
453 // an empty variable name is useless: we reject it.
454 k, v, _ := strings.Cut(line, "=")
455 if k == "" {
456 return out, ghErrorf("Invalid format '%s'. Name must not be empty", line)
457 }
458 key, output = k, v
459 case heredoc >= 0 && (equals < 0 || heredoc < equals):
460 k, delim, _ := strings.Cut(line, "<<")
461 if k == "" || delim == "" {
462 return out, ghErrorf("Invalid format '%s'. Name must not be empty and delimiter must not be empty", line)
463 }
464 key = k
465 start, end := index, index
466 temp, newline, tok := readLine(text, &index)
467 for temp != delim || !tok {
468 if !tok {
469 return out, ghErrorf("Invalid value. Matching delimiter not found '%s'", delim)
470 }
471 if newline == "" {
472 return out, ghErrorf("Invalid value. EOF marker missing new line.")
473 }
474 end = index - len(newline)
475 temp, newline, tok = readLine(text, &index)
476 }
477 if end > start {
478 output = text[start:end]
479 }
480 default:
481 return out, ghErrorf("Invalid format '%s'", line)
482 }
483 out = append(out, Pair{key, output})
484 }
485 line, _, ok = readLine(text, &index)
486 }
487 return out, nil
488}
489
490// readLine is the private ReadLine of EnvFileKeyValuePairs (:462): it returns
491// the line without its "\n", the newline ("\n" or "" at the end of the text)
492// and false once the text is exhausted.
493func readLine(text string, index *int) (line, newline string, ok bool) {
494 if *index >= len(text) {
495 return "", "", false
496 }
497 orig := *index
498 lf := strings.IndexByte(text[orig:], '\n')
499 if lf < 0 {
500 *index = len(text)
501 return text[orig:], "", true
502 }
503 *index = orig + lf + 1
504 return text[orig : orig+lf], "\n", true
505}
506
507// PrependedPath returns the PATH value that results from the AddPath calls
508// recorded in list (in call order) in front of orig, like
509// Handler.AddPrependPathToEnvironment (Handler.cs:205) with PathUtil.PrependPath.
510func PrependedPath(list []string, orig string) string {
511 if len(list) == 0 {
512 return orig
513 }
514 rev := slices.Clone(list)
515 slices.Reverse(rev)
516 prepend := strings.Join(rev, ":")
517 if orig == "" {
518 return prepend
519 }
520 if strings.HasPrefix(orig, prepend+":") {
521 return orig
522 }
523 return prepend + ":" + orig
524}