fix/step-umask a16908b

filecmd_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"os"
7	"path/filepath"
8	"reflect"
9	"strings"
10	"syscall"
11	"testing"
12	"time"
13)
14
15// Cases ported from SetEnvFileCommandL0, SetOutputFileCommandL0 and
16// SaveStateFileCommandL0 (actions/runner, src/Test/L0/Worker). The C# tests
17// write their files with a UTF-8 BOM and join lines with the platform newline;
18// "\n" is used here and the BOM is added by writeEnvFile.
19
20func TestParseEnvFile(t *testing.T) {
21	type kv = Pair
22	tests := []struct {
23		name    string
24		in      string
25		want    []kv
26		wantErr string
27	}{
28		{"empty file", "", nil, ""},
29		{"simple", "MY_ENV=MY VALUE", []kv{{"MY_ENV", "MY VALUE"}}, ""},
30		{"skip empty lines", "\nMY_ENV=my value\n\nMY_ENV_2=my second value\n", []kv{{"MY_ENV", "my value"}, {"MY_ENV_2", "my second value"}}, ""},
31		{"empty value", "MY_ENV=", []kv{{"MY_ENV", ""}}, ""},
32		{"multiple values", "MY_ENV=my value\nMY_ENV_2=\nMY_ENV_3=my third value", []kv{{"MY_ENV", "my value"}, {"MY_ENV_2", ""}, {"MY_ENV_3", "my third value"}}, ""},
33		{"special characters", "MY_ENV==abc\nMY_ENV_2=def=ghi\nMY_ENV_3=jkl=", []kv{{"MY_ENV", "=abc"}, {"MY_ENV_2", "def=ghi"}, {"MY_ENV_3", "jkl="}}, ""},
34		{"heredoc", "MY_ENV<<EOF\nline one\nline two\nline three\nEOF", []kv{{"MY_ENV", "line one\nline two\nline three"}}, ""},
35		{"heredoc trailing newline", "MY_ENV<<EOF\nhello\nworld\nEOF\n", []kv{{"MY_ENV", "hello\nworld"}}, ""},
36		{"heredoc empty value", "MY_ENV<<EOF\nEOF", []kv{{"MY_ENV", ""}}, ""},
37		{"heredoc skip empty lines", "\nMY_ENV<<EOF\nhello\nworld\nEOF\n\nMY_ENV_2<<EOF\nHELLO\nAGAIN\nEOF\n", []kv{{"MY_ENV", "hello\nworld"}, {"MY_ENV_2", "HELLO\nAGAIN"}}, ""},
38		{"heredoc special characters",
39			"MY_ENV<<=EOF\nhello\none\n=EOF\nMY_ENV_2<<<EOF\nhello\ntwo\n<EOF\nMY_ENV_3<<EOF\nhello\n\nthree\n\nEOF\nMY_ENV_4<<EOF\nhello=four\nEOF\nMY_ENV_5<<EOF\n EOF\nEOF",
40			[]kv{{"MY_ENV", "hello\none"}, {"MY_ENV_2", "hello\ntwo"}, {"MY_ENV_3", "hello\n\nthree\n"}, {"MY_ENV_4", "hello=four"}, {"MY_ENV_5", " EOF"}}, ""},
41		{"heredoc delimiter missing", "MY_ENV<<EOF line one line two line three EOF", nil, "Matching delimiter not found"},
42		{"heredoc missing delimiter at end of text", "MY_ENV<<EOF\nline one\nline two", nil, "EOF marker missing new line"},
43		{"heredoc multi-line missing newline", "MY_ENV<<EOF line one\n line two\n line three EOF", nil, "EOF marker missing new line"},
44		{"equals first wins over heredoc", "A=b<<C", []kv{{"A", "b<<C"}}, ""},
45		{"heredoc first wins over equals", "A<<B=C\nx\nB=C", []kv{{"A", "x"}}, ""},
46		{"no separator", "just text", nil, "Invalid format 'just text'"},
47		{"empty name", "=value", nil, "Name must not be empty"},
48		{"empty heredoc name", "<<EOF\nx\nEOF", nil, "Name must not be empty and delimiter must not be empty"},
49		{"empty delimiter", "A<<\nx", nil, "delimiter must not be empty"},
50		{"pairs before the error are kept", "A=1\nbroken", []kv{{"A", "1"}}, "Invalid format 'broken'"},
51		{"CR is data", "A=1\r\n", []kv{{"A", "1\r"}}, ""},
52	}
53	for _, tc := range tests {
54		t.Run(tc.name, func(t *testing.T) {
55			got, err := ParseEnvFile(tc.in)
56			if tc.wantErr != "" {
57				if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
58					t.Fatalf("error %v, want %q", err, tc.wantErr)
59				}
60			} else if err != nil {
61				t.Fatal(err)
62			}
63			if !reflect.DeepEqual(got, tc.want) {
64				t.Fatalf("got %q, want %q", got, tc.want)
65			}
66		})
67	}
68}
69
70// fakeSink records the effects of file commands.
71type fakeSink struct {
72	env, outputs, state map[string]string
73	path                []string
74	errors, debugs      []string
75	failed              bool
76}
77
78func newSink() *fakeSink {
79	return &fakeSink{env: map[string]string{}, outputs: map[string]string{}, state: map[string]string{}}
80}
81func (s *fakeSink) SetEnv(k, v string)    { s.env[k] = v }
82func (s *fakeSink) SetOutput(k, v string) { s.outputs[k] = v }
83func (s *fakeSink) SaveState(k, v string) { s.state[k] = v }
84func (s *fakeSink) AddPath(p string)      { s.path = append(s.path, p) }
85func (s *fakeSink) Error(m string)        { s.errors = append(s.errors, m) }
86func (s *fakeSink) Debug(m string)        { s.debugs = append(s.debugs, m) }
87func (s *fakeSink) CommandFailed()        { s.failed = true }
88
89func writeEnvFile(t *testing.T, path, content string) {
90	t.Helper()
91	// The C# tests write a BOM; ReadAllText drops it.
92	if err := os.WriteFile(path, []byte("\xef\xbb\xbf"+content), 0o644); err != nil {
93		t.Fatal(err)
94	}
95}
96
97func TestFileCommandsInit(t *testing.T) {
98	dir := filepath.Join(t.TempDir(), "_runner_file_commands")
99	fc, err := NewFileCommands(dir)
100	if err != nil {
101		t.Fatal(err)
102	}
103	env, err := fc.Init()
104	if err != nil {
105		t.Fatal(err)
106	}
107	for _, name := range []string{"GITHUB_PATH", "GITHUB_ENV", "GITHUB_STEP_SUMMARY", "GITHUB_STATE", "GITHUB_OUTPUT", "GITHUB_ARTIFACTS", "GITHUB_ARTIFACTS_LIST"} {
108		p := env[name]
109		if p == "" || filepath.Dir(p) != dir {
110			t.Fatalf("%s = %q", name, p)
111		}
112		if st, err := os.Stat(p); err != nil || st.Size() != 0 {
113			t.Fatalf("%s: %v %v", name, st, err)
114		}
115	}
116	if !strings.HasPrefix(filepath.Base(env["GITHUB_ENV"]), "set_env_") || !strings.HasPrefix(filepath.Base(env["GITHUB_PATH"]), "add_path_") {
117		t.Fatalf("prefixes: %v", env)
118	}
119	if !strings.HasPrefix(filepath.Base(env["GITHUB_ARTIFACTS"]), "artifacts_") || !strings.HasPrefix(filepath.Base(env["GITHUB_ARTIFACTS_LIST"]), "artifacts_list_") {
120		t.Fatalf("artifact prefixes: %v", env)
121	}
122	// A second Init uses new names and keeps the previous files: the runner
123	// makes a new FileCommandManager for each step, which never deletes an old
124	// file (recorded: sum-env).
125	env2, _ := fc.Init()
126	if env2["GITHUB_ENV"] == env["GITHUB_ENV"] {
127		t.Fatal("same file for two steps")
128	}
129	if _, err := os.Stat(env["GITHUB_ENV"]); err != nil {
130		t.Fatalf("old file removed: %v", err)
131	}
132}
133
134func TestProcessAllCommands(t *testing.T) {
135	fc, _ := NewFileCommands(t.TempDir())
136	env, _ := fc.Init()
137	writeEnvFile(t, env["GITHUB_ENV"], "A=1\nM<<X\nl1\nl2\nX\n")
138	writeEnvFile(t, env["GITHUB_OUTPUT"], "o=2\n")
139	writeEnvFile(t, env["GITHUB_STATE"], "s=3\n")
140	writeEnvFile(t, env["GITHUB_PATH"], "/a\n\n/b\n/a\n")
141	writeEnvFile(t, env["GITHUB_STEP_SUMMARY"], "# hi")
142	s := newSink()
143	fc.Process(s)
144	if !reflect.DeepEqual(s.env, map[string]string{"A": "1", "M": "l1\nl2"}) || s.outputs["o"] != "2" || s.state["s"] != "3" {
145		t.Fatalf("%+v", s)
146	}
147	if !reflect.DeepEqual(s.path, []string{"/a", "/b", "/a"}) {
148		t.Fatalf("path %v", s.path)
149	}
150	if s.failed || len(s.errors) != 0 {
151		t.Fatalf("errors %v", s.errors)
152	}
153}
154
155func TestProcessBlockedEnvNames(t *testing.T) {
156	// SetEnvFileCommand_BlockListItemsFiltered and _Heredoc.
157	for _, content := range []string{"NODE_OPTIONS=asdf", "NODE_OPTIONS<<EOF\nasdf\nEOF", "node_options=asdf"} {
158		fc, _ := NewFileCommands(t.TempDir())
159		env, _ := fc.Init()
160		writeEnvFile(t, env["GITHUB_ENV"], content)
161		s := newSink()
162		fc.Process(s)
163		if len(s.env) != 0 || len(s.errors) != 1 || s.errors[0] != "Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command." || s.failed {
164			t.Fatalf("%q: env=%v errors=%v failed=%v", content, s.env, s.errors, s.failed)
165		}
166	}
167}
168
169func TestProcessFormatErrorFailsTheStep(t *testing.T) {
170	fc, _ := NewFileCommands(t.TempDir())
171	env, _ := fc.Init()
172	writeEnvFile(t, env["GITHUB_OUTPUT"], "good=1\nbad line\n")
173	writeEnvFile(t, env["GITHUB_ENV"], "E=1\n")
174	s := newSink()
175	fc.Process(s)
176	// Pairs before the error are applied; other commands still run.
177	if s.outputs["good"] != "1" || s.env["E"] != "1" || !s.failed {
178		t.Fatalf("%+v", s)
179	}
180	want := []string{"Unable to process file command 'output' successfully.", "Invalid format 'bad line'"}
181	if !reflect.DeepEqual(s.errors, want) {
182		t.Fatalf("errors %q", s.errors)
183	}
184}
185
186func TestProcessMissingFilesAreSilent(t *testing.T) {
187	// *_DirectoryNotFound and *_NotFound: nothing is set, no issue.
188	fc, _ := NewFileCommands(t.TempDir())
189	env, _ := fc.Init()
190	for _, p := range env {
191		_ = os.Remove(p)
192	}
193	s := newSink()
194	fc.Process(s)
195	if len(s.env)+len(s.outputs)+len(s.state)+len(s.path) != 0 || len(s.errors) != 0 || s.failed {
196		t.Fatalf("%+v", s)
197	}
198}
199
200func TestStepSummarySize(t *testing.T) {
201	fc, _ := NewFileCommands(t.TempDir())
202	env, _ := fc.Init()
203	if err := os.WriteFile(env["GITHUB_STEP_SUMMARY"], make([]byte, 1024*1024+1), 0o644); err != nil {
204		t.Fatal(err)
205	}
206	s := newSink()
207	fc.Process(s)
208	if len(s.errors) != 1 || !strings.HasPrefix(s.errors[0], "$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of 1024k, got 1024k.") {
209		t.Fatalf("%q", s.errors)
210	}
211	if s.failed {
212		t.Fatal("an oversized summary only logs an error")
213	}
214}
215
216func TestSplitLines(t *testing.T) {
217	got := splitLines("a\r\nb\rc\n\nd")
218	if !reflect.DeepEqual(got, []string{"a", "b", "c", "", "d"}) {
219		t.Fatalf("%q", got)
220	}
221}
222
223func TestPrependedPath(t *testing.T) {
224	// PathUtil.PrependPath: the last added entry comes first; no trailing
225	// separator on an empty PATH; nothing changes when already first.
226	if got := PrependedPath([]string{"/a", "/b"}, "/usr/bin"); got != "/b:/a:/usr/bin" {
227		t.Fatal(got)
228	}
229	if got := PrependedPath([]string{"/a"}, ""); got != "/a" {
230		t.Fatal(got)
231	}
232	if got := PrependedPath([]string{"/a"}, "/a:/usr/bin"); got != "/a:/usr/bin" {
233		t.Fatal(got)
234	}
235	if got := PrependedPath(nil, "/usr/bin"); got != "/usr/bin" {
236		t.Fatal(got)
237	}
238}
239
240// ---- the directory belongs to the user of the steps: nothing may be followed
241
242func TestFileCommandSymlinkIsNotFollowed(t *testing.T) {
243	for _, name := range []string{"GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH", "GITHUB_STATE", "GITHUB_STEP_SUMMARY"} {
244		t.Run(name, func(t *testing.T) {
245			secret := filepath.Join(t.TempDir(), "secret")
246			if err := os.WriteFile(secret, []byte("STOLEN=1\n/stolen/bin\n"), 0o600); err != nil {
247				t.Fatal(err)
248			}
249			fc, err := NewFileCommands(t.TempDir())
250			if err != nil {
251				t.Fatal(err)
252			}
253			defer func() { _ = fc.Close() }()
254			env, _ := fc.Init()
255			// The step replaces its file by a link to a file only root may read.
256			p := env[name]
257			if err := os.Remove(p); err != nil {
258				t.Fatal(err)
259			}
260			if err := os.Symlink(secret, p); err != nil {
261				t.Fatal(err)
262			}
263			s := newSink()
264			fc.Process(s)
265			if len(s.env)+len(s.outputs)+len(s.state)+len(s.path) != 0 {
266				t.Fatalf("the content of the target was read: %+v", s)
267			}
268			if !s.failed || len(s.errors) == 0 {
269				t.Fatalf("a replaced file must fail the step: %+v", s)
270			}
271		})
272	}
273}
274
275func TestFileCommandFifoDoesNotBlock(t *testing.T) {
276	fc, _ := NewFileCommands(t.TempDir())
277	defer func() { _ = fc.Close() }()
278	env, _ := fc.Init()
279	p := env["GITHUB_OUTPUT"]
280	_ = os.Remove(p)
281	if err := syscall.Mkfifo(p, 0o644); err != nil {
282		t.Skip(err)
283	}
284	done := make(chan *fakeSink, 1)
285	go func() { s := newSink(); fc.Process(s); done <- s }()
286	select {
287	case s := <-done:
288		if !s.failed {
289			t.Fatalf("%+v", s)
290		}
291	case <-time.After(5 * time.Second):
292		t.Fatal("reading a FIFO blocked the executor")
293	}
294}
295
296func TestFileCommandDirectoryReplacedByASymlink(t *testing.T) {
297	outside := t.TempDir()
298	if err := os.WriteFile(filepath.Join(outside, "set_env_x"), []byte("LEAK=1\n"), 0o600); err != nil {
299		t.Fatal(err)
300	}
301	parent := t.TempDir()
302	dir := filepath.Join(parent, "cmds")
303	fc, err := NewFileCommands(dir)
304	if err != nil {
305		t.Fatal(err)
306	}
307	defer func() { _ = fc.Close() }()
308	if _, err := fc.Init(); err != nil {
309		t.Fatal(err)
310	}
311	// The step swaps the directory for a link to another one.
312	if err := os.Rename(dir, dir+".old"); err != nil {
313		t.Fatal(err)
314	}
315	if err := os.Symlink(outside, dir); err != nil {
316		t.Fatal(err)
317	}
318	fc.suffix = "x"
319	s := newSink()
320	fc.Process(s)
321	if len(s.env) != 0 {
322		t.Fatalf("a file of the swapped directory was read: %v", s.env)
323	}
324	// Init keeps working on the original directory, not on the link's target.
325	env, err := fc.Init()
326	if err != nil {
327		t.Fatal(err)
328	}
329	if _, err := os.Stat(filepath.Join(outside, filepath.Base(env["GITHUB_ENV"]))); err == nil {
330		t.Fatal("a file was created through the swapped directory")
331	}
332}
333
334func TestCreateExclReplacesAndNeverFollows(t *testing.T) {
335	dir := t.TempDir()
336	root, err := os.OpenRoot(dir)
337	if err != nil {
338		t.Fatal(err)
339	}
340	defer func() { _ = root.Close() }()
341	victim := filepath.Join(t.TempDir(), "victim")
342	if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil {
343		t.Fatal(err)
344	}
345	if err := os.Symlink(victim, filepath.Join(dir, "f")); err != nil {
346		t.Fatal(err)
347	}
348	if err := createExcl(root, "f", []byte("new"), 0o644, nil); err != nil {
349		t.Fatal(err)
350	}
351	if b, _ := os.ReadFile(victim); string(b) != "keep" {
352		t.Fatalf("the target of the link was written: %q", b)
353	}
354	if b, _ := os.ReadFile(filepath.Join(dir, "f")); string(b) != "new" {
355		t.Fatalf("%q", b)
356	}
357	if fi, _ := os.Lstat(filepath.Join(dir, "f")); fi.Mode()&os.ModeSymlink != 0 {
358		t.Fatal("still a link")
359	}
360}
361
362// The owner is applied to the open file (GO-003): chown by name after the close
363// would follow a name that the user of the steps could have swapped. Own uid and
364// gid are accepted by an unprivileged process, so the path runs here.
365func TestCreateExclChownsTheOpenFile(t *testing.T) {
366	dir := t.TempDir()
367	root, err := os.OpenRoot(dir)
368	if err != nil {
369		t.Fatal(err)
370	}
371	defer func() { _ = root.Close() }()
372	owner := &[2]int{os.Getuid(), os.Getgid()}
373	if err := createExcl(root, "f", []byte("x"), 0o644, owner); err != nil {
374		t.Fatal(err)
375	}
376	// A uid that this process may not give files to makes the creation fail, and
377	// the half-made file is not reported as success.
378	if os.Getuid() != 0 {
379		if err := createExcl(root, "g", []byte("x"), 0o644, &[2]int{0, 0}); err == nil {
380			t.Fatal("chown to root by an unprivileged process succeeded")
381		}
382	}
383}
384
385// The summary is read from the descriptor that was checked, at most the limit
386// plus one byte: a file that grows after the size check (a background process
387// of the step) cannot make the executor read it whole (GO-005).
388func TestStepSummaryGrowingAfterTheSizeCheckIsBounded(t *testing.T) {
389	fc, _ := NewFileCommands(t.TempDir())
390	env, _ := fc.Init()
391	path := env["GITHUB_STEP_SUMMARY"]
392	if err := os.WriteFile(path, []byte("small\n"), 0o644); err != nil {
393		t.Fatal(err)
394	}
395	afterSummaryStat = func() {
396		f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
397		if err != nil {
398			t.Error(err)
399			return
400		}
401		defer func() { _ = f.Close() }()
402		if _, err := f.Write(make([]byte, 8<<20)); err != nil {
403			t.Error(err)
404		}
405	}
406	t.Cleanup(func() { afterSummaryStat = nil })
407	s := &summarySinkFake{fakeSink: newSink()}
408	fc.Process(s)
409	if s.got != "" {
410		t.Errorf("a summary of %d bytes was kept", len(s.got))
411	}
412	if len(s.errors) != 1 || !strings.HasPrefix(s.errors[0], "$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of 1024k, got 1024k.") {
413		t.Fatalf("%q", s.errors)
414	}
415}
416
417type summarySinkFake struct {
418	*fakeSink
419	got string
420}
421
422func (s *summarySinkFake) Summary(m string) { s.got = m }

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