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 (after deleting the ones
152// of the previous step) and returns the environment variables that name them
153// (InitializeFiles, FileCommandManager.cs:42).
154func (f *FileCommands) Init() (map[string]string, error) {
155 old := f.suffix
156 f.suffix = uuid.NewString()
157 env := make(map[string]string, len(fileCommands))
158 for _, c := range fileCommands {
159 if old != "" {
160 _ = f.root.Remove(f.name(c, old))
161 }
162 if err := createExcl(f.root, f.name(c, f.suffix), nil, 0o644, f.owner); err != nil {
163 return nil, err
164 }
165 env[envVarName(c.ContextName)] = f.path(c, f.suffix)
166 }
167 return env, nil
168}
169
170// Process reads the files of the step that just ran and applies them to sink.
171// A command that fails does not stop the others (ProcessFiles, :75).
172func (f *FileCommands) Process(sink Sink) {
173 for _, c := range fileCommands {
174 p := f.name(c, f.suffix)
175 var err error
176 switch c.ContextName {
177 case cmdPath:
178 err = processAddPath(sink, f.root, p)
179 case cmdEnv:
180 err = processSetEnv(sink, f.root, p)
181 case cmdStepSummary:
182 err = processStepSummary(sink, f.root, p)
183 case cmdState:
184 err = processPairs(sink, f.root, p, sink.SaveState, func(k, v string) { sink.Debug(fmt.Sprintf("Save intra-action state %s = %s", k, v)) })
185 case cmdArtifacts, cmdArtifactsList:
186 // Disabled feature: nothing to process (see fileCommands).
187 case cmdOutput:
188 err = processPairs(sink, f.root, p, sink.SetOutput, func(k, v string) { sink.Debug(fmt.Sprintf("Set output %s = %s", k, v)) })
189 }
190 if err != nil {
191 sink.Error(fmt.Sprintf("Unable to process file command '%s' successfully.", c.ContextName))
192 sink.Error(err.Error())
193 sink.CommandFailed()
194 }
195 }
196}
197
198// errNotRegular is the error of a file command file that was replaced by
199// something else (a symbolic link, a FIFO, a directory).
200var errNotRegular = ghErrorf("The file is not a regular file")
201
202// openRegular opens name in root for reading without following a symbolic link
203// and without blocking on a FIFO, and checks that it is a regular file.
204func openRegular(root *os.Root, name string) (*os.File, error) {
205 fh, err := root.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
206 if err != nil {
207 if errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.EMLINK) {
208 return nil, errNotRegular
209 }
210 return nil, err
211 }
212 st, err := fh.Stat()
213 if err != nil {
214 _ = fh.Close()
215 return nil, err
216 }
217 if !st.Mode().IsRegular() {
218 _ = fh.Close()
219 return nil, errNotRegular
220 }
221 return fh, nil
222}
223
224// readText is File.ReadAllText: a UTF-8 BOM is dropped and invalid bytes
225// become U+FFFD. A missing file gives ok=false and no error (the callers
226// debug-log it; AddPath and StepSummary skip silently); a file that is not a
227// regular file is an error.
228func readText(root *os.Root, name string) (text string, ok bool, err error) {
229 fh, err := openRegular(root, name)
230 if errors.Is(err, os.ErrNotExist) {
231 return "", false, nil
232 }
233 if err != nil {
234 return "", false, err
235 }
236 defer func() { _ = fh.Close() }()
237 b, err := io.ReadAll(fh)
238 if err != nil {
239 return "", false, err
240 }
241 b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf"))
242 if !utf8.Valid(b) {
243 b = bytes.ToValidUTF8(b, []byte("\ufffd"))
244 }
245 return string(b), true, nil
246}
247
248// createExcl creates name in root with the content, failing if it exists (an
249// existing entry, a symbolic link included, is removed first, never followed),
250// and gives it to owner when set. The creation uses O_EXCL|O_NOFOLLOW, so
251// something planted between the removal and the creation makes it fail. The
252// owner is set on the open file, not by name: the user of the steps owns the
253// directory and could swap the name for something else between the close and a
254// chown by name.
255func createExcl(root *os.Root, name string, content []byte, perm os.FileMode, owner *[2]int) error {
256 if err := root.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) {
257 return err
258 }
259 fh, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, perm)
260 if err != nil {
261 return err
262 }
263 if _, err := fh.Write(content); err != nil {
264 _ = fh.Close()
265 return err
266 }
267 if owner != nil {
268 if err := fh.Chown(owner[0], owner[1]); err != nil {
269 _ = fh.Close()
270 return err
271 }
272 }
273 return fh.Close()
274}
275
276// processAddPath is AddPathFileCommand.ProcessCommand (FileCommandManager.cs:134).
277func processAddPath(sink Sink, root *os.Root, name string) error {
278 text, ok, err := readText(root, name)
279 if err != nil || !ok {
280 return err
281 }
282 // File.ReadAllLines splits on \r\n, \n and \r.
283 for _, line := range splitLines(text) {
284 if line == "" {
285 continue
286 }
287 sink.AddPath(line)
288 }
289 return nil
290}
291
292// splitLines is File.ReadAllLines / StreamReader.ReadLine: \r\n, \r and \n
293// end a line; no empty last line is produced.
294func splitLines(s string) []string {
295 var out []string
296 for len(s) > 0 {
297 i := strings.IndexAny(s, "\r\n")
298 if i < 0 {
299 out = append(out, s)
300 break
301 }
302 out = append(out, s[:i])
303 if s[i] == '\r' && i+1 < len(s) && s[i+1] == '\n' {
304 i++
305 }
306 s = s[i+1:]
307 }
308 return out
309}
310
311// processSetEnv is SetEnvFileCommand.ProcessCommand (:167). Pairs read before
312// a format error are applied, like the lazy enumeration of the original.
313func processSetEnv(sink Sink, root *os.Root, name string) error {
314 pairs, err := readEnvFile(sink, root, name)
315 for _, p := range pairs {
316 if blocked := blockedEnvName(p.Key); blocked != "" {
317 sink.Error(fmt.Sprintf("Can't store %s output parameter using '$GITHUB_ENV' command.", blocked))
318 continue
319 }
320 sink.SetEnv(p.Key, p.Value)
321 sink.Debug(fmt.Sprintf("%s='%s'", p.Key, p.Value))
322 }
323 return err
324}
325
326func blockedEnvName(name string) string {
327 for _, b := range setEnvBlockList {
328 if strings.EqualFold(b, name) {
329 return b
330 }
331 }
332 return ""
333}
334
335// processPairs serves GITHUB_STATE and GITHUB_OUTPUT.
336func processPairs(sink Sink, root *os.Root, name string, apply func(k, v string), debug func(k, v string)) error {
337 pairs, err := readEnvFile(sink, root, name)
338 for _, p := range pairs {
339 apply(p.Key, p.Value)
340 debug(p.Key, p.Value)
341 }
342 return err
343}
344
345func readEnvFile(sink Sink, root *os.Root, name string) ([]Pair, error) {
346 text, ok, err := readText(root, name)
347 if err != nil {
348 return nil, err
349 }
350 if !ok {
351 sink.Debug(fmt.Sprintf("File does not exist '%s'", filepath.Join(root.Name(), name)))
352 return nil, nil
353 }
354 return ParseEnvFile(text)
355}
356
357// processStepSummary is CreateStepSummaryCommand.ProcessCommand (:230) for
358// CI-1: the file is only checked (an empty file is ignored, a file above 1 MiB
359// is an error); ermite does not store summaries yet.
360func processStepSummary(sink Sink, root *os.Root, name string) error {
361 fh, err := openRegular(root, name)
362 if errors.Is(err, os.ErrNotExist) {
363 return nil
364 }
365 if err != nil {
366 return err
367 }
368 st, err := fh.Stat()
369 _ = fh.Close()
370 if err != nil || st.Size() == 0 {
371 return nil
372 }
373 if st.Size() > stepSummaryLimit {
374 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, st.Size()/1024))
375 }
376 return nil
377}
378
379// ParseEnvFile parses the contents of GITHUB_ENV, GITHUB_OUTPUT or
380// GITHUB_STATE: EnvFileKeyValuePairs.GetEnumerator (FileCommandManager.cs:364).
381//
382// Lines are NAME=VALUE (split at the first "=") or heredocs NAME<<DELIMITER up
383// to a line equal to DELIMITER; the form whose marker comes first wins. Empty
384// lines between entries are skipped. Only "\n" ends a line (on Linux). The
385// pairs read before an error are returned with it, because the original
386// applies them as it enumerates.
387func ParseEnvFile(text string) ([]Pair, error) {
388 var out []Pair
389 index := 0
390 line, _, ok := readLine(text, &index)
391 for ok {
392 if line != "" {
393 var key, output string
394 equals := strings.Index(line, "=")
395 heredoc := strings.Index(line, "<<")
396 switch {
397 case equals >= 0 && (heredoc < 0 || equals < heredoc):
398 // The original tests string.IsNullOrEmpty(line) here, which cannot
399 // be true; the intent (the message says so) is an empty name, and
400 // an empty variable name is useless: we reject it.
401 k, v, _ := strings.Cut(line, "=")
402 if k == "" {
403 return out, ghErrorf("Invalid format '%s'. Name must not be empty", line)
404 }
405 key, output = k, v
406 case heredoc >= 0 && (equals < 0 || heredoc < equals):
407 k, delim, _ := strings.Cut(line, "<<")
408 if k == "" || delim == "" {
409 return out, ghErrorf("Invalid format '%s'. Name must not be empty and delimiter must not be empty", line)
410 }
411 key = k
412 start, end := index, index
413 temp, newline, tok := readLine(text, &index)
414 for temp != delim || !tok {
415 if !tok {
416 return out, ghErrorf("Invalid value. Matching delimiter not found '%s'", delim)
417 }
418 if newline == "" {
419 return out, ghErrorf("Invalid value. EOF marker missing new line.")
420 }
421 end = index - len(newline)
422 temp, newline, tok = readLine(text, &index)
423 }
424 if end > start {
425 output = text[start:end]
426 }
427 default:
428 return out, ghErrorf("Invalid format '%s'", line)
429 }
430 out = append(out, Pair{key, output})
431 }
432 line, _, ok = readLine(text, &index)
433 }
434 return out, nil
435}
436
437// readLine is the private ReadLine of EnvFileKeyValuePairs (:462): it returns
438// the line without its "\n", the newline ("\n" or "" at the end of the text)
439// and false once the text is exhausted.
440func readLine(text string, index *int) (line, newline string, ok bool) {
441 if *index >= len(text) {
442 return "", "", false
443 }
444 orig := *index
445 lf := strings.IndexByte(text[orig:], '\n')
446 if lf < 0 {
447 *index = len(text)
448 return text[orig:], "", true
449 }
450 *index = orig + lf + 1
451 return text[orig : orig+lf], "\n", true
452}
453
454// PrependedPath returns the PATH value that results from the AddPath calls
455// recorded in list (in call order) in front of orig, like
456// Handler.AddPrependPathToEnvironment (Handler.cs:205) with PathUtil.PrependPath.
457func PrependedPath(list []string, orig string) string {
458 if len(list) == 0 {
459 return orig
460 }
461 rev := slices.Clone(list)
462 slices.Reverse(rev)
463 prepend := strings.Join(rev, ":")
464 if orig == "" {
465 return prepend
466 }
467 if strings.HasPrefix(orig, prepend+":") {
468 return orig
469 }
470 return prepend + ":" + orig
471}