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 }