v0.1.0 cbb8ad6

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 deletes the previous files and uses new names.
123	env2, _ := fc.Init()
124	if env2["GITHUB_ENV"] == env["GITHUB_ENV"] {
125		t.Fatal("same file for two steps")
126	}
127	if _, err := os.Stat(env["GITHUB_ENV"]); !os.IsNotExist(err) {
128		t.Fatalf("old file kept: %v", err)
129	}
130}
131
132func TestProcessAllCommands(t *testing.T) {
133	fc, _ := NewFileCommands(t.TempDir())
134	env, _ := fc.Init()
135	writeEnvFile(t, env["GITHUB_ENV"], "A=1\nM<<X\nl1\nl2\nX\n")
136	writeEnvFile(t, env["GITHUB_OUTPUT"], "o=2\n")
137	writeEnvFile(t, env["GITHUB_STATE"], "s=3\n")
138	writeEnvFile(t, env["GITHUB_PATH"], "/a\n\n/b\n/a\n")
139	writeEnvFile(t, env["GITHUB_STEP_SUMMARY"], "# hi")
140	s := newSink()
141	fc.Process(s)
142	if !reflect.DeepEqual(s.env, map[string]string{"A": "1", "M": "l1\nl2"}) || s.outputs["o"] != "2" || s.state["s"] != "3" {
143		t.Fatalf("%+v", s)
144	}
145	if !reflect.DeepEqual(s.path, []string{"/a", "/b", "/a"}) {
146		t.Fatalf("path %v", s.path)
147	}
148	if s.failed || len(s.errors) != 0 {
149		t.Fatalf("errors %v", s.errors)
150	}
151}
152
153func TestProcessBlockedEnvNames(t *testing.T) {
154	// SetEnvFileCommand_BlockListItemsFiltered and _Heredoc.
155	for _, content := range []string{"NODE_OPTIONS=asdf", "NODE_OPTIONS<<EOF\nasdf\nEOF", "node_options=asdf"} {
156		fc, _ := NewFileCommands(t.TempDir())
157		env, _ := fc.Init()
158		writeEnvFile(t, env["GITHUB_ENV"], content)
159		s := newSink()
160		fc.Process(s)
161		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 {
162			t.Fatalf("%q: env=%v errors=%v failed=%v", content, s.env, s.errors, s.failed)
163		}
164	}
165}
166
167func TestProcessFormatErrorFailsTheStep(t *testing.T) {
168	fc, _ := NewFileCommands(t.TempDir())
169	env, _ := fc.Init()
170	writeEnvFile(t, env["GITHUB_OUTPUT"], "good=1\nbad line\n")
171	writeEnvFile(t, env["GITHUB_ENV"], "E=1\n")
172	s := newSink()
173	fc.Process(s)
174	// Pairs before the error are applied; other commands still run.
175	if s.outputs["good"] != "1" || s.env["E"] != "1" || !s.failed {
176		t.Fatalf("%+v", s)
177	}
178	want := []string{"Unable to process file command 'output' successfully.", "Invalid format 'bad line'"}
179	if !reflect.DeepEqual(s.errors, want) {
180		t.Fatalf("errors %q", s.errors)
181	}
182}
183
184func TestProcessMissingFilesAreSilent(t *testing.T) {
185	// *_DirectoryNotFound and *_NotFound: nothing is set, no issue.
186	fc, _ := NewFileCommands(t.TempDir())
187	env, _ := fc.Init()
188	for _, p := range env {
189		_ = os.Remove(p)
190	}
191	s := newSink()
192	fc.Process(s)
193	if len(s.env)+len(s.outputs)+len(s.state)+len(s.path) != 0 || len(s.errors) != 0 || s.failed {
194		t.Fatalf("%+v", s)
195	}
196}
197
198func TestStepSummarySize(t *testing.T) {
199	fc, _ := NewFileCommands(t.TempDir())
200	env, _ := fc.Init()
201	if err := os.WriteFile(env["GITHUB_STEP_SUMMARY"], make([]byte, 1024*1024+1), 0o644); err != nil {
202		t.Fatal(err)
203	}
204	s := newSink()
205	fc.Process(s)
206	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.") {
207		t.Fatalf("%q", s.errors)
208	}
209	if s.failed {
210		t.Fatal("an oversized summary only logs an error")
211	}
212}
213
214func TestSplitLines(t *testing.T) {
215	got := splitLines("a\r\nb\rc\n\nd")
216	if !reflect.DeepEqual(got, []string{"a", "b", "c", "", "d"}) {
217		t.Fatalf("%q", got)
218	}
219}
220
221func TestPrependedPath(t *testing.T) {
222	// PathUtil.PrependPath: the last added entry comes first; no trailing
223	// separator on an empty PATH; nothing changes when already first.
224	if got := PrependedPath([]string{"/a", "/b"}, "/usr/bin"); got != "/b:/a:/usr/bin" {
225		t.Fatal(got)
226	}
227	if got := PrependedPath([]string{"/a"}, ""); got != "/a" {
228		t.Fatal(got)
229	}
230	if got := PrependedPath([]string{"/a"}, "/a:/usr/bin"); got != "/a:/usr/bin" {
231		t.Fatal(got)
232	}
233	if got := PrependedPath(nil, "/usr/bin"); got != "/usr/bin" {
234		t.Fatal(got)
235	}
236}
237
238// ---- the directory belongs to the user of the steps: nothing may be followed
239
240func TestFileCommandSymlinkIsNotFollowed(t *testing.T) {
241	for _, name := range []string{"GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH", "GITHUB_STATE", "GITHUB_STEP_SUMMARY"} {
242		t.Run(name, func(t *testing.T) {
243			secret := filepath.Join(t.TempDir(), "secret")
244			if err := os.WriteFile(secret, []byte("STOLEN=1\n/stolen/bin\n"), 0o600); err != nil {
245				t.Fatal(err)
246			}
247			fc, err := NewFileCommands(t.TempDir())
248			if err != nil {
249				t.Fatal(err)
250			}
251			defer func() { _ = fc.Close() }()
252			env, _ := fc.Init()
253			// The step replaces its file by a link to a file only root may read.
254			p := env[name]
255			if err := os.Remove(p); err != nil {
256				t.Fatal(err)
257			}
258			if err := os.Symlink(secret, p); err != nil {
259				t.Fatal(err)
260			}
261			s := newSink()
262			fc.Process(s)
263			if len(s.env)+len(s.outputs)+len(s.state)+len(s.path) != 0 {
264				t.Fatalf("the content of the target was read: %+v", s)
265			}
266			if !s.failed || len(s.errors) == 0 {
267				t.Fatalf("a replaced file must fail the step: %+v", s)
268			}
269		})
270	}
271}
272
273func TestFileCommandFifoDoesNotBlock(t *testing.T) {
274	fc, _ := NewFileCommands(t.TempDir())
275	defer func() { _ = fc.Close() }()
276	env, _ := fc.Init()
277	p := env["GITHUB_OUTPUT"]
278	_ = os.Remove(p)
279	if err := syscall.Mkfifo(p, 0o644); err != nil {
280		t.Skip(err)
281	}
282	done := make(chan *fakeSink, 1)
283	go func() { s := newSink(); fc.Process(s); done <- s }()
284	select {
285	case s := <-done:
286		if !s.failed {
287			t.Fatalf("%+v", s)
288		}
289	case <-time.After(5 * time.Second):
290		t.Fatal("reading a FIFO blocked the executor")
291	}
292}
293
294func TestFileCommandDirectoryReplacedByASymlink(t *testing.T) {
295	outside := t.TempDir()
296	if err := os.WriteFile(filepath.Join(outside, "set_env_x"), []byte("LEAK=1\n"), 0o600); err != nil {
297		t.Fatal(err)
298	}
299	parent := t.TempDir()
300	dir := filepath.Join(parent, "cmds")
301	fc, err := NewFileCommands(dir)
302	if err != nil {
303		t.Fatal(err)
304	}
305	defer func() { _ = fc.Close() }()
306	if _, err := fc.Init(); err != nil {
307		t.Fatal(err)
308	}
309	// The step swaps the directory for a link to another one.
310	if err := os.Rename(dir, dir+".old"); err != nil {
311		t.Fatal(err)
312	}
313	if err := os.Symlink(outside, dir); err != nil {
314		t.Fatal(err)
315	}
316	fc.suffix = "x"
317	s := newSink()
318	fc.Process(s)
319	if len(s.env) != 0 {
320		t.Fatalf("a file of the swapped directory was read: %v", s.env)
321	}
322	// Init keeps working on the original directory, not on the link's target.
323	env, err := fc.Init()
324	if err != nil {
325		t.Fatal(err)
326	}
327	if _, err := os.Stat(filepath.Join(outside, filepath.Base(env["GITHUB_ENV"]))); err == nil {
328		t.Fatal("a file was created through the swapped directory")
329	}
330}
331
332func TestCreateExclReplacesAndNeverFollows(t *testing.T) {
333	dir := t.TempDir()
334	root, err := os.OpenRoot(dir)
335	if err != nil {
336		t.Fatal(err)
337	}
338	defer func() { _ = root.Close() }()
339	victim := filepath.Join(t.TempDir(), "victim")
340	if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil {
341		t.Fatal(err)
342	}
343	if err := os.Symlink(victim, filepath.Join(dir, "f")); err != nil {
344		t.Fatal(err)
345	}
346	if err := createExcl(root, "f", []byte("new"), 0o644, nil); err != nil {
347		t.Fatal(err)
348	}
349	if b, _ := os.ReadFile(victim); string(b) != "keep" {
350		t.Fatalf("the target of the link was written: %q", b)
351	}
352	if b, _ := os.ReadFile(filepath.Join(dir, "f")); string(b) != "new" {
353		t.Fatalf("%q", b)
354	}
355	if fi, _ := os.Lstat(filepath.Join(dir, "f")); fi.Mode()&os.ModeSymlink != 0 {
356		t.Fatal("still a link")
357	}
358}
359
360// The owner is applied to the open file (GO-003): chown by name after the close
361// would follow a name that the user of the steps could have swapped. Own uid and
362// gid are accepted by an unprivileged process, so the path runs here.
363func TestCreateExclChownsTheOpenFile(t *testing.T) {
364	dir := t.TempDir()
365	root, err := os.OpenRoot(dir)
366	if err != nil {
367		t.Fatal(err)
368	}
369	defer func() { _ = root.Close() }()
370	owner := &[2]int{os.Getuid(), os.Getgid()}
371	if err := createExcl(root, "f", []byte("x"), 0o644, owner); err != nil {
372		t.Fatal(err)
373	}
374	// A uid that this process may not give files to makes the creation fail, and
375	// the half-made file is not reported as success.
376	if os.Getuid() != 0 {
377		if err := createExcl(root, "g", []byte("x"), 0o644, &[2]int{0, 0}); err == nil {
378			t.Fatal("chown to root by an unprivileged process succeeded")
379		}
380	}
381}

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