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}