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