v0.1.0 cbb8ad6

filecmd.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close