1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "github.com/google/uuid"
12)
13
14// Shells and script files of `run:` steps: a port of ScriptHandlerHelpers.cs
15// and of the parts of ScriptHandler.cs that build the command line
16// (Runner.Worker/Handlers). Only the Linux behaviour is ported.
17
18// defaultArguments is ScriptHandlerHelpers._defaultArguments
19// (ScriptHandlerHelpers.cs:13). Keys are compared case-insensitively.
20var defaultArguments = map[string]string{
21 "cmd": `/D /E:ON /V:OFF /S /C "CALL "{0}""`,
22 "pwsh": `-command ". '{0}'"`,
23 "powershell": `-command ". '{0}'"`,
24 "bash": "--noprofile --norc -e -o pipefail {0}",
25 "sh": "-e {0}",
26 "python": "{0}",
27}
28
29// scriptExtensions is ScriptHandlerHelpers._extensions (:23).
30var scriptExtensions = map[string]string{
31 "cmd": ".cmd",
32 "pwsh": ".ps1",
33 "powershell": ".ps1",
34 "bash": ".sh",
35 "sh": ".sh",
36 "python": ".py",
37}
38
39// ScriptArgumentsFormat is GetScriptArgumentsFormat (:33): the argument format
40// of a built-in shell, "" for any other command.
41func ScriptArgumentsFormat(shellType string) string {
42 return defaultArguments[strings.ToLower(shellType)]
43}
44
45// ScriptFileExtension is GetScriptFileExtension (:42): ".sh", ".py", ... or "".
46func ScriptFileExtension(shellType string) string {
47 return scriptExtensions[strings.ToLower(shellType)]
48}
49
50// FixUpScriptContents is ScriptHandlerHelpers.FixUpScriptContents (:51). The
51// switch is case-sensitive, unlike the two lookups above.
52func FixUpScriptContents(shellType, contents string) string {
53 switch shellType {
54 case "cmd":
55 // Use @echo off rather than /Q: with /Q, echo can't be turned on.
56 return "@echo off\n" + contents
57 case "powershell", "pwsh":
58 return "$ErrorActionPreference = 'stop'\n" + contents + "\n" +
59 `if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }`
60 }
61 return contents
62}
63
64// ParseShellOption is ParseShellOptionString (:70): "COMMAND [..ARGS]" split at
65// the first space.
66func ParseShellOption(shell string) (command, args string) {
67 command, args, _ = strings.Cut(shell, " ")
68 return command, args
69}
70
71// ErrInvalidShell is the error of a shell whose argument format has no {0}
72// (ScriptHandler.cs:245).
73var ErrInvalidShell error = msgError("Invalid shell option. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{0}'")
74
75// Lookup finds executables like WhichUtil.Which (Runner.Sdk/Util/WhichUtil.cs):
76// in PATH with the directories added by GITHUB_PATH in front.
77type Lookup struct {
78 // Path is the PATH of the executor process (not of the step).
79 Path string
80 // Prepend is Global.PrependPath in the order of the AddPath calls.
81 Prepend []string
82}
83
84// NotFoundError is the FileNotFoundException of Which(require: true).
85type NotFoundError struct{ Command string }
86
87func (e *NotFoundError) Error() string { return e.Command + ": command not found" }
88
89// Which returns the path of command, or "" when it is not found.
90func (l Lookup) Which(command string) string {
91 if filepath.IsAbs(command) {
92 if isFile(command) {
93 return command
94 }
95 }
96 path := PrependedPath(l.Prepend, l.Path)
97 for _, seg := range strings.Split(path, ":") {
98 if seg == "" {
99 continue
100 }
101 seg = os.ExpandEnv(seg)
102 // Directory.EnumerateFiles(seg, command): a plain file name in the directory.
103 if strings.ContainsRune(command, '/') {
104 continue
105 }
106 p := filepath.Join(seg, command)
107 if isFile(p) {
108 return p
109 }
110 }
111 return ""
112}
113
114// isFile reports whether p is a file, or a symbolic link whose target exists
115// (WhichUtil.IsPathValid).
116func isFile(p string) bool {
117 st, err := os.Stat(p)
118 return err == nil && !st.IsDir()
119}
120
121// Require is Which(command, require: true).
122func (l Lookup) Require(command string) (string, error) {
123 if p := l.Which(command); p != "" {
124 return p, nil
125 }
126 return "", &NotFoundError{command}
127}
128
129// ShellPlan is how a step's script is run: the shell chosen by the step, the
130// job defaults, or the runner's default.
131type ShellPlan struct {
132 // Command is the shell command: "sh" for the default shell (whose path is
133 // bash when found), else the first word of the shell option.
134 Command string
135 // CommandPath is the executable that is started, and shown on the
136 // "shell:" line of the log.
137 CommandPath string
138 // ArgFormat is the argument format with {0} for the script path.
139 ArgFormat string
140}
141
142// PlanShell chooses the shell like ScriptHandler.RunAsync (:170-234) and
143// PrintActionDetails (:65-117). shell is the step's `shell` or, when empty,
144// defaults.run.shell (the caller does that merge, :171-180).
145//
146// With no shell: bash if it is in PATH, else sh, with the arguments of "sh"
147// (-e {0}). "sh" is only the name used for the arguments and the extension.
148func PlanShell(shell string, l Lookup) (ShellPlan, error) {
149 if shell == "" {
150 p, err := l.lookupBashOrSh()
151 if err != nil {
152 return ShellPlan{}, err
153 }
154 return checkFormat(ShellPlan{Command: "sh", CommandPath: p, ArgFormat: ScriptArgumentsFormat("sh")})
155 }
156 cmd, args := ParseShellOption(shell)
157 p, err := l.Require(cmd)
158 if err != nil {
159 return ShellPlan{}, err
160 }
161 format := strings.TrimLeft(args, " \t\n\v\f\r")
162 if format == "" {
163 format = ScriptArgumentsFormat(cmd)
164 }
165 return checkFormat(ShellPlan{Command: cmd, CommandPath: p, ArgFormat: format})
166}
167
168func (l Lookup) lookupBashOrSh() (string, error) {
169 if p := l.Which("bash"); p != "" {
170 return p, nil
171 }
172 return l.Require("sh")
173}
174
175func checkFormat(p ShellPlan) (ShellPlan, error) {
176 if p.ArgFormat == "" || !strings.Contains(p.ArgFormat, "{0}") {
177 return p, ErrInvalidShell
178 }
179 return p, nil
180}
181
182// ShellLine is the text after "shell: " in the step header (:121-126).
183func (p ShellPlan) ShellLine() string { return p.CommandPath + " " + p.ArgFormat }
184
185// Extension is the extension of the script file, from the shell command.
186func (p ShellPlan) Extension() string { return ScriptFileExtension(p.Command) }
187
188// FormatArguments is string.Format(argFormat, path) of .NET with one argument:
189// {0} is the path, {{ and }} are braces; anything else is a format error. The
190// script path has its double quotes escaped first (ScriptHandler.cs:252).
191func FormatArguments(format, scriptPath string) (string, error) {
192 path := strings.ReplaceAll(scriptPath, `"`, `\"`)
193 var b strings.Builder
194 for i := 0; i < len(format); i++ {
195 switch c := format[i]; {
196 case c == '{' && i+1 < len(format) && format[i+1] == '{':
197 b.WriteByte('{')
198 i++
199 case c == '}' && i+1 < len(format) && format[i+1] == '}':
200 b.WriteByte('}')
201 i++
202 case c == '{':
203 end := strings.IndexByte(format[i:], '}')
204 if end < 0 {
205 return "", ghErrorf("Input string was not in a correct format.")
206 }
207 spec := format[i+1 : i+end]
208 index, _, _ := strings.Cut(spec, ",")
209 index, _, _ = strings.Cut(index, ":")
210 if strings.TrimSpace(index) != "0" {
211 return "", ghErrorf("Index (zero based) must be greater than or equal to zero and less than the size of the argument list.")
212 }
213 b.WriteString(path)
214 i += end
215 case c == '}':
216 return "", ghErrorf("Input string was not in a correct format.")
217 default:
218 b.WriteByte(c)
219 }
220 }
221 return b.String(), nil
222}
223
224// ParseArguments splits an argument string into arguments the way .NET does
225// for Process.Start on Unix (Process.Unix.cs ParseArgumentsIntoList): spaces
226// and tabs separate; double quotes group; backslashes only count before a
227// quote.
228func ParseArguments(s string) []string {
229 var out []string
230 for i := 0; i < len(s); i++ {
231 for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
232 i++
233 }
234 if i == len(s) {
235 break
236 }
237 var cur strings.Builder
238 inQuotes := false
239 for i < len(s) {
240 n := 0
241 for i < len(s) && s[i] == '\\' {
242 i++
243 n++
244 }
245 if n > 0 {
246 if i >= len(s) || s[i] != '"' {
247 cur.WriteString(strings.Repeat(`\`, n))
248 } else {
249 cur.WriteString(strings.Repeat(`\`, n/2))
250 if n%2 != 0 {
251 cur.WriteByte('"')
252 i++
253 }
254 }
255 continue
256 }
257 c := s[i]
258 if c == '"' {
259 if inQuotes && i < len(s)-1 && s[i+1] == '"' {
260 cur.WriteByte('"')
261 i++
262 } else {
263 inQuotes = !inQuotes
264 }
265 i++
266 continue
267 }
268 if (c == ' ' || c == '\t') && !inQuotes {
269 break
270 }
271 cur.WriteByte(c)
272 i++
273 }
274 out = append(out, cur.String())
275 }
276 return out
277}
278
279// Script is a script written to the temp directory, ready to run.
280type Script struct {
281 // Path is the script file.
282 Path string
283 // Argv is the command: the shell and its arguments with the script path.
284 Argv []string
285}
286
287// WriteScript writes the script of a step to the directory of root (RUNNER_TEMP)
288// under a random name with the extension of the shell, and builds the command
289// line (ScriptHandler.RunAsync :247-285). The file has no BOM and is not
290// executable: the shell reads it. The directory belongs to the user of the steps,
291// so the file is created through root, exclusively and without following links
292// (see createExcl).
293func WriteScript(root *os.Root, p ShellPlan, contents string) (Script, error) {
294 return WriteScriptFor(root, p, contents, nil)
295}
296
297// WriteScriptFor is WriteScript for a shell that sees the file under another
298// path: tr translates the host path for the arguments (a job container sees
299// RUNNER_TEMP as /__w/_temp, ScriptHandler.cs:252). Script.Path stays the
300// host's.
301func WriteScriptFor(root *os.Root, p ShellPlan, contents string, tr func(string) string) (Script, error) {
302 name := uuid.NewString() + p.Extension()
303 path := filepath.Join(root.Name(), name)
304 shown := path
305 if tr != nil {
306 shown = tr(path)
307 }
308 args, err := FormatArguments(p.ArgFormat, shown)
309 if err != nil {
310 return Script{}, err
311 }
312 contents = FixUpScriptContents(p.Command, contents)
313 if err := createExcl(root, name, []byte(contents), 0o644, nil); err != nil {
314 return Script{}, fmt.Errorf("write script: %w", err)
315 }
316 return Script{Path: path, Argv: append([]string{p.CommandPath}, ParseArguments(args)...)}, nil
317}
318
319// ResolveWorkingDirectory is Path.Combine(workspace, workingDirectory ?? "")
320// (ScriptHandler.cs:168): an absolute directory replaces the workspace.
321func ResolveWorkingDirectory(workspace, dir string) string {
322 if filepath.IsAbs(dir) {
323 return dir
324 }
325 if dir == "" {
326 return workspace
327 }
328 return strings.TrimRight(workspace, "/") + "/" + dir
329}
330
331// PlanShellInContainer is the shell of a step that runs in the job container
332// (ScriptHandler.cs:73-117, :182-232 with isContainerStepHost): nothing is
333// looked up, the shell is found by the container itself, so the default is
334// always "sh -e {0}" even when the image has bash.
335func PlanShellInContainer(shell string) (ShellPlan, error) {
336 if shell == "" {
337 return checkFormat(ShellPlan{Command: "sh", CommandPath: "sh", ArgFormat: ScriptArgumentsFormat("sh")})
338 }
339 cmd, args := ParseShellOption(shell)
340 format := strings.TrimLeft(args, " \t\n\v\f\r")
341 if format == "" {
342 format = ScriptArgumentsFormat(cmd)
343 }
344 return checkFormat(ShellPlan{Command: cmd, CommandPath: cmd, ArgFormat: format})
345}