1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "os"
7 "path/filepath"
8 "reflect"
9 "strings"
10 "testing"
11)
12
13func fakeBin(t *testing.T, names ...string) string {
14 t.Helper()
15 dir := t.TempDir()
16 for _, n := range names {
17 if err := os.WriteFile(filepath.Join(dir, n), []byte("#!/bin/sh\n"), 0o755); err != nil {
18 t.Fatal(err)
19 }
20 }
21 return dir
22}
23
24func TestBuiltinFormatsAndExtensions(t *testing.T) {
25 // ScriptHandlerHelpers.cs _defaultArguments and _extensions (Linux rows).
26 for shell, want := range map[string][2]string{
27 "bash": {"--noprofile --norc -e -o pipefail {0}", ".sh"},
28 "sh": {"-e {0}", ".sh"},
29 "python": {"{0}", ".py"},
30 "pwsh": {`-command ". '{0}'"`, ".ps1"},
31 "powershell": {`-command ". '{0}'"`, ".ps1"},
32 "cmd": {`/D /E:ON /V:OFF /S /C "CALL "{0}""`, ".cmd"},
33 "BASH": {"--noprofile --norc -e -o pipefail {0}", ".sh"}, // case-insensitive tables
34 "python3": {"", ""},
35 "ruby": {"", ""},
36 } {
37 if got := ScriptArgumentsFormat(shell); got != want[0] {
38 t.Errorf("format(%s) = %q", shell, got)
39 }
40 if got := ScriptFileExtension(shell); got != want[1] {
41 t.Errorf("extension(%s) = %q", shell, got)
42 }
43 }
44}
45
46func TestFixUpScriptContents(t *testing.T) {
47 if got := FixUpScriptContents("cmd", "echo hi"); got != "@echo off\necho hi" {
48 t.Fatal(got)
49 }
50 want := "$ErrorActionPreference = 'stop'\nGet-Date\nif ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) { exit $LASTEXITCODE }"
51 for _, s := range []string{"pwsh", "powershell"} {
52 if got := FixUpScriptContents(s, "Get-Date"); got != want {
53 t.Fatalf("%s: %q", s, got)
54 }
55 }
56 // The switch is case-sensitive: PWSH is left alone.
57 for _, s := range []string{"bash", "sh", "python", "PWSH", "Cmd"} {
58 if got := FixUpScriptContents(s, "x"); got != "x" {
59 t.Fatalf("%s: %q", s, got)
60 }
61 }
62}
63
64func TestParseShellOption(t *testing.T) {
65 for in, want := range map[string][2]string{
66 "bash": {"bash", ""},
67 "bash -x {0}": {"bash", "-x {0}"},
68 "python3 {0}": {"python3", "{0}"},
69 "perl -e {0}": {"perl", " -e {0}"}, // Split(" ", 2) keeps the second space
70 "": {"", ""},
71 "node -e \"x y\" {0}": {"node", "-e \"x y\" {0}"},
72 } {
73 c, a := ParseShellOption(in)
74 if c != want[0] || a != want[1] {
75 t.Errorf("%q: %q %q", in, c, a)
76 }
77 }
78}
79
80func TestPlanShell(t *testing.T) {
81 both := fakeBin(t, "bash", "sh", "python", "python3", "perl")
82 onlySh := fakeBin(t, "sh")
83 l := Lookup{Path: both}
84 tests := []struct {
85 name string
86 shell string
87 l Lookup
88 want ShellPlan
89 err string
90 }{
91 {"default uses bash with the arguments of sh", "", l, ShellPlan{"sh", filepath.Join(both, "bash"), "-e {0}"}, ""},
92 {"default falls back to sh", "", Lookup{Path: onlySh}, ShellPlan{"sh", filepath.Join(onlySh, "sh"), "-e {0}"}, ""},
93 {"bash", "bash", l, ShellPlan{"bash", filepath.Join(both, "bash"), "--noprofile --norc -e -o pipefail {0}"}, ""},
94 {"sh", "sh", l, ShellPlan{"sh", filepath.Join(both, "sh"), "-e {0}"}, ""},
95 {"python", "python", l, ShellPlan{"python", filepath.Join(both, "python"), "{0}"}, ""},
96 {"custom with args", "python3 {0}", l, ShellPlan{"python3", filepath.Join(both, "python3"), "{0}"}, ""},
97 {"custom args are left-trimmed", "perl -w {0}", l, ShellPlan{"perl", filepath.Join(both, "perl"), "-w {0}"}, ""},
98 {"bash with explicit args", "bash -x {0}", l, ShellPlan{"bash", filepath.Join(both, "bash"), "-x {0}"}, ""},
99 {"unknown command", "nope {0}", l, ShellPlan{}, "nope: command not found"},
100 {"pwsh not installed", "pwsh", l, ShellPlan{}, "pwsh: command not found"},
101 {"no sh at all", "", Lookup{Path: t.TempDir()}, ShellPlan{}, "sh: command not found"},
102 {"custom without {0}", "perl -w", l, ShellPlan{}, "Invalid shell option"},
103 {"no format for an unknown shell", "perl", l, ShellPlan{}, "Invalid shell option"},
104 }
105 for _, tc := range tests {
106 t.Run(tc.name, func(t *testing.T) {
107 got, err := PlanShell(tc.shell, tc.l)
108 if tc.err != "" {
109 if err == nil || !strings.Contains(err.Error(), tc.err) {
110 t.Fatalf("err = %v, want %q", err, tc.err)
111 }
112 return
113 }
114 if err != nil || got != tc.want {
115 t.Fatalf("got %+v, %v; want %+v", got, err, tc.want)
116 }
117 })
118 }
119}
120
121func TestPlanShellUsesPrependedPath(t *testing.T) {
122 sys, extra := fakeBin(t, "sh"), fakeBin(t, "bash")
123 p, err := PlanShell("", Lookup{Path: sys, Prepend: []string{extra}})
124 if err != nil || p.CommandPath != filepath.Join(extra, "bash") {
125 t.Fatalf("%+v %v", p, err)
126 }
127}
128
129func TestShellLine(t *testing.T) {
130 p := ShellPlan{"sh", "/usr/bin/bash", "-e {0}"}
131 if p.ShellLine() != "/usr/bin/bash -e {0}" || p.Extension() != ".sh" {
132 t.Fatal(p.ShellLine(), p.Extension())
133 }
134}
135
136func TestFormatArguments(t *testing.T) {
137 for _, tc := range []struct{ format, path, want, err string }{
138 {"-e {0}", "/t/a.sh", "-e /t/a.sh", ""},
139 {"{0}", "/t/a b.sh", "/t/a b.sh", ""},
140 {"-c '{0}' {0}", "/t/x", "-c '/t/x' /t/x", ""},
141 {"{{0}} {0}", "/t/x", "{0} /t/x", ""},
142 {"x }}", "/t/x", "x }", ""},
143 {"{0}", `/t/a"b`, `/t/a\"b`, ""}, // double quotes in the path are escaped
144 {"{0:D}", "/t/x", "/t/x", ""},
145 {"{1}", "/t/x", "", "Index"},
146 {"{0", "/t/x", "", "not in a correct format"},
147 {"a } b", "/t/x", "", "not in a correct format"},
148 } {
149 got, err := FormatArguments(tc.format, tc.path)
150 if tc.err != "" {
151 if err == nil || !strings.Contains(err.Error(), tc.err) {
152 t.Errorf("%q: err = %v", tc.format, err)
153 }
154 continue
155 }
156 if err != nil || got != tc.want {
157 t.Errorf("%q: %q %v", tc.format, got, err)
158 }
159 }
160}
161
162func TestParseArguments(t *testing.T) {
163 for in, want := range map[string][]string{
164 "": nil,
165 " ": nil,
166 "-e /t/a.sh": {"-e", "/t/a.sh"},
167 "--noprofile --norc -e -o pipefail /t/a.sh": {"--noprofile", "--norc", "-e", "-o", "pipefail", "/t/a.sh"},
168 `-c "a b" c`: {"-c", "a b", "c"},
169 `a\"b`: {`a"b`},
170 `a\\"b c"`: {`a\b c`},
171 `a\\b`: {`a\\b`},
172 `"a""b"`: {`a"b`},
173 "a\tb": {"a", "b"},
174 `-command ". '/t/x.ps1'"`: {"-command", ". '/t/x.ps1'"},
175 `""`: {""},
176 } {
177 if got := ParseArguments(in); !reflect.DeepEqual(got, want) {
178 t.Errorf("%q: %q, want %q", in, got, want)
179 }
180 }
181}
182
183func TestWriteScript(t *testing.T) {
184 dir := t.TempDir()
185 root, err := os.OpenRoot(dir)
186 if err != nil {
187 t.Fatal(err)
188 }
189 defer func() { _ = root.Close() }()
190 bin := fakeBin(t, "bash", "sh", "python")
191 l := Lookup{Path: bin}
192 for _, tc := range []struct {
193 shell, ext string
194 args []string
195 }{
196 {"", ".sh", []string{"-e"}},
197 {"bash", ".sh", []string{"--noprofile", "--norc", "-e", "-o", "pipefail"}},
198 {"python", ".py", nil},
199 } {
200 plan, err := PlanShell(tc.shell, l)
201 if err != nil {
202 t.Fatal(err)
203 }
204 s, err := WriteScript(root, plan, "echo hi\n")
205 if err != nil {
206 t.Fatal(err)
207 }
208 if filepath.Dir(s.Path) != dir || filepath.Ext(s.Path) != tc.ext {
209 t.Fatalf("%s: path %s", tc.shell, s.Path)
210 }
211 want := append(append([]string{plan.CommandPath}, tc.args...), s.Path)
212 if !reflect.DeepEqual(s.Argv, want) {
213 t.Fatalf("%s: argv %q, want %q", tc.shell, s.Argv, want)
214 }
215 b, _ := os.ReadFile(s.Path)
216 if string(b) != "echo hi\n" {
217 t.Fatalf("contents %q", b)
218 }
219 }
220 // A custom shell without an extension in the table: no extension.
221 plan, _ := PlanShell("python {0}", l)
222 if s, _ := WriteScript(root, plan, "x"); filepath.Ext(s.Path) != ".py" {
223 t.Fatalf("python {0}: %s", s.Path)
224 }
225 bin2 := fakeBin(t, "perl")
226 plan, _ = PlanShell("perl {0}", Lookup{Path: bin2})
227 if s, _ := WriteScript(root, plan, "x"); filepath.Ext(s.Path) != "" {
228 t.Fatalf("perl: %s", s.Path)
229 }
230}
231
232func TestResolveWorkingDirectory(t *testing.T) {
233 for _, tc := range [][3]string{
234 {"/w/r/r", "", "/w/r/r"},
235 {"/w/r/r", "sub", "/w/r/r/sub"},
236 {"/w/r/r", "a/b", "/w/r/r/a/b"},
237 {"/w/r/r", "/tmp", "/tmp"},
238 {"/w/r/r", "..", "/w/r/r/.."},
239 } {
240 if got := ResolveWorkingDirectory(tc[0], tc[1]); got != tc[2] {
241 t.Errorf("%v: %q", tc, got)
242 }
243 }
244}
245
246// The script goes through the os.Root of RUNNER_TEMP: a directory swapped for a
247// link to elsewhere after the root was opened does not redirect it (GO-002).
248func TestWriteScriptStaysInTheOpenedDirectory(t *testing.T) {
249 parent := t.TempDir()
250 dir := filepath.Join(parent, "tmp")
251 if err := os.Mkdir(dir, 0o755); err != nil {
252 t.Fatal(err)
253 }
254 root, err := os.OpenRoot(dir)
255 if err != nil {
256 t.Fatal(err)
257 }
258 defer func() { _ = root.Close() }()
259 outside := filepath.Join(parent, "outside")
260 if err := os.Mkdir(outside, 0o755); err != nil {
261 t.Fatal(err)
262 }
263 // The step swaps the directory for a link to another one.
264 if err := os.Rename(dir, dir+".moved"); err != nil {
265 t.Fatal(err)
266 }
267 if err := os.Symlink(outside, dir); err != nil {
268 t.Fatal(err)
269 }
270 bin := fakeBin(t, "bash", "sh")
271 plan, _ := PlanShell("", Lookup{Path: bin})
272 if _, err := WriteScript(root, plan, "echo hi\n"); err != nil {
273 t.Fatal(err)
274 }
275 if ents, _ := os.ReadDir(outside); len(ents) != 0 {
276 t.Fatalf("the script was written through the swapped link: %v", ents)
277 }
278 if ents, _ := os.ReadDir(dir + ".moved"); len(ents) != 1 {
279 t.Fatalf("the script is not in the directory that root was opened on: %v", ents)
280 }
281}