1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "errors"
7 "fmt"
8 "io/fs"
9 "os"
10 "path/filepath"
11 "strings"
12 "unicode"
13)
14
15// Workflow commands in the output of a step: a port of ActionCommand.cs
16// (Runner.Common) and of ActionCommandManager.cs.
17
18type escapeMapping struct{ token, replacement string }
19
20// _escapeMappings, _escapeDataMappings, _escapePropertyMappings of
21// ActionCommand.cs:11-35, in order: the replacements run one after the other.
22var (
23 escapeMappings = []escapeMapping{{";", "%3B"}, {"\r", "%0D"}, {"\n", "%0A"}, {"]", "%5D"}, {"%", "%25"}}
24 escapeData = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {"%", "%25"}}
25 escapeProperty = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {":", "%3A"}, {",", "%2C"}, {"%", "%25"}}
26)
27
28func unescape(s string, maps []escapeMapping) string {
29 for _, m := range maps {
30 s = strings.ReplaceAll(s, m.replacement, m.token)
31 }
32 return s
33}
34
35// actionCommand is a parsed workflow command.
36type actionCommand struct {
37 Name string
38 Props map[string]string // keys compared case-insensitively (lower-cased)
39 Data string
40}
41
42func (c *actionCommand) prop(name string) string { return c.Props[strings.ToLower(name)] }
43
44const commandKey = "::"
45
46// parseCommandV2 is ActionCommand.TryParseV2 (ActionCommand.cs:57): "::name
47// key=value,key=value::data".
48func parseCommandV2(message string, registered func(string) bool) (*actionCommand, bool) {
49 // .NET TrimStart() trims Unicode white space.
50 message = strings.TrimLeftFunc(message, unicode.IsSpace)
51 if !strings.HasPrefix(message, commandKey) {
52 return nil, false
53 }
54 end := strings.Index(message[len(commandKey):], commandKey)
55 if end < 0 {
56 return nil, false
57 }
58 end += len(commandKey)
59 info := message[len(commandKey):end]
60 name, rest, hasSpace := strings.Cut(info, " ")
61 if !registered(name) {
62 return nil, false
63 }
64 cmd := &actionCommand{Name: name, Props: map[string]string{}}
65 if hasSpace && len(info) > len(name) { // spaceIndex > 0
66 for _, p := range strings.Split(strings.TrimSpace(rest), ",") {
67 if p == "" {
68 continue
69 }
70 k, v, ok := strings.Cut(p, "=")
71 if ok && k != "" && v != "" { // Split(RemoveEmptyEntries): both parts non-empty
72 cmd.Props[strings.ToLower(k)] = unescape(v, escapeProperty)
73 }
74 }
75 }
76 cmd.Data = unescape(message[end+len(commandKey):], escapeData)
77 return cmd, true
78}
79
80// parseCommandV1 is ActionCommand.TryParse (ActionCommand.cs:127): the legacy
81// "##[name key=value;key=value]data".
82func parseCommandV1(message string, registered func(string) bool) (*actionCommand, bool) {
83 const prefix = "##["
84 pi := strings.Index(message, prefix)
85 if pi < 0 {
86 return nil, false
87 }
88 rb := strings.IndexByte(message[pi:], ']')
89 if rb < 0 {
90 return nil, false
91 }
92 rb += pi
93 info := message[pi+len(prefix) : rb]
94 name, rest, hasSpace := strings.Cut(info, " ")
95 if !registered(name) {
96 return nil, false
97 }
98 cmd := &actionCommand{Name: name, Props: map[string]string{}}
99 if hasSpace && len(info) > len(name) {
100 for _, p := range strings.Split(rest, ";") {
101 if p == "" {
102 continue
103 }
104 k, v, ok := strings.Cut(p, "=")
105 if ok && k != "" && v != "" {
106 cmd.Props[strings.ToLower(k)] = unescape(v, escapeMappings)
107 }
108 }
109 }
110 cmd.Data = unescape(message[rb+1:], escapeMappings)
111 return cmd, true
112}
113
114// Messages of Constants.cs:234-236.
115const (
116 unsupportedCommandMessage = "The `%s` command is deprecated and will be disabled soon. Please upgrade to using Environment Files. For more information see: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/"
117 unsupportedCommandMessageDisabled = "The `%s` command is disabled. Please upgrade to using Environment Files or opt into unsecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_COMMANDS` environment variable to `true`. For more information see: https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/"
118 unsupportedStopTokenDisabled = "You cannot use a endToken that is an empty string, the string 'pause-logging', or another workflow command. For more information see: https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#example-stopping-and-starting-workflow-commands or opt into insecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS` environment variable to `true`."
119)
120
121// commandNames are the commands the executor registers, in ActionCommandManager
122// (the names of the extensions at ActionCommandManager.cs).
123var commandNames = []string{
124 "set-env", "set-output", "save-state", "add-mask", "add-path", "add-matcher", "remove-matcher",
125 "debug", "warning", "error", "notice", "group", "endgroup", "echo", "stop-commands",
126}
127
128// commandHost is what commands act on: the step that is running.
129type commandHost interface {
130 // Out writes a log line (several when it contains line feeds).
131 Out(text string)
132 // Tagged writes a ##[tag]message line.
133 Tagged(tag, message string)
134 Debug(msg string)
135 SetOutput(name, value string)
136 SaveState(name, value string)
137 SetEnv(name, value string)
138 AddPath(path string)
139 CommandFailed()
140 // EnvFlag reads a variable of the env context, then of the process.
141 EnvFlag(name string) bool
142 // AddIssue is ExecutionContext.AddIssue: it logs the issue and keeps it for
143 // the annotations of the step.
144 AddIssue(issue)
145 // Workspace is github.workspace.
146 Workspace() string
147 // HostPath translates a path printed by a process in the job container
148 // back to the host's (ContainerInfo.TranslateToHostPath); without a
149 // container it is the identity.
150 HostPath(p string) string
151 // AddMask is SecretMasker.AddValue.
152 AddMask(value string)
153 // Matchers is the root of the problem matchers of the job.
154 Matchers() *matcherRoot
155 // ReadMatcherFile reads a problem matcher file (IOUtil.LoadObject).
156 ReadMatcherFile(path string) ([]byte, error)
157}
158
159// commandManager is ActionCommandManager: one per step.
160type commandManager struct {
161 host commandHost
162 registered map[string]bool
163 stopped bool
164 stopToken string
165 echo bool
166 // fatal is the exception that ActionCommandManager.ValidateStopToken throws
167 // outside the try block of TryProcessCommand: it leaves OutputDataReceived,
168 // ProcessInvoker.ExecuteAsync and the step with it, so the step fails with
169 // the message alone and the output that follows is never read. The
170 // official runner leaves the process running (ProcessInvoker is only
171 // disposed; "Complete job" kills it as an orphan); ermite stops it through
172 // the cancellation path (SIGINT, SIGTERM, SIGKILL) so that the step does not
173 // wait for it, and discards what it still writes.
174 fatal error
175 // onFatal, when set, is called once when fatal is set: the process that
176 // writes the output is then stopped (runProc).
177 onFatal func()
178}
179
180func newCommandManager(h commandHost, echo bool) *commandManager {
181 m := &commandManager{host: h, registered: map[string]bool{}, echo: echo}
182 for _, n := range commandNames {
183 m.registered[strings.ToLower(n)] = true
184 }
185 return m
186}
187
188func (m *commandManager) isRegistered(name string) bool { return m.registered[strings.ToLower(name)] }
189
190// TryProcess handles one line of output. It returns true when the line was a
191// command and must not be logged as plain text (ActionCommandManager.cs:61).
192func (m *commandManager) TryProcess(input string) bool {
193 if input == "" || m.fatal != nil {
194 return false
195 }
196 cmd, ok := parseCommandV2(input, m.isRegistered)
197 if !ok {
198 cmd, ok = parseCommandV1(input, m.isRegistered)
199 }
200 if !ok {
201 return false
202 }
203 name := strings.ToLower(cmd.Name)
204 if m.stopped {
205 if m.stopToken != "" && strings.EqualFold(cmd.Name, m.stopToken) {
206 m.host.Out(input)
207 m.host.Debug("Resume processing commands")
208 delete(m.registered, strings.ToLower(m.stopToken))
209 m.stopped, m.stopToken = false, ""
210 return true
211 }
212 m.host.Debug(fmt.Sprintf("Process commands has been stopped and waiting for '##[%s]' to resume.", m.stopToken))
213 return false
214 }
215 if name == "stop-commands" {
216 if err := m.validateStopToken(cmd.Data); err != nil {
217 m.fatal = err
218 if m.onFatal != nil {
219 m.onFatal()
220 }
221 return true
222 }
223 m.stopToken, m.stopped = cmd.Data, true
224 m.registered[strings.ToLower(cmd.Data)] = true
225 if len(m.stopToken) > 6 { // ActionCommandManager.cs:117
226 m.host.AddMask(m.stopToken)
227 }
228 m.host.Out(input)
229 m.host.Debug("Paused processing commands until the token you called ::stopCommands:: with is received")
230 return true
231 }
232 omitEcho := name == "debug" || name == "warning" || name == "error" || name == "notice" || name == "add-mask"
233 if m.echo && !omitEcho {
234 m.host.Out(input)
235 }
236 if err := m.run(name, cmd); err != nil {
237 m.fail(input, omitEcho, err)
238 }
239 return true
240}
241
242func (m *commandManager) fail(input string, omitEcho bool, err error) {
243 info := input
244 if omitEcho {
245 info = ""
246 }
247 m.host.Tagged("##[error]", fmt.Sprintf("Unable to process command '%s' successfully.", info))
248 m.host.Tagged("##[error]", err.Error())
249 m.host.CommandFailed()
250}
251
252func (m *commandManager) validateStopToken(token string) error {
253 invalid := m.isRegistered(token) || token == "" || strings.EqualFold(token, "pause-logging")
254 if invalid && !m.host.EnvFlag("ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS") {
255 return msgError(unsupportedStopTokenDisabled)
256 }
257 return nil
258}
259
260func (m *commandManager) run(name string, c *actionCommand) error {
261 h := m.host
262 switch name {
263 case "group":
264 h.Out("##[group]" + c.Data) // GroupingCommandExtension, ActionCommandManager.cs:802
265 case "endgroup":
266 h.Out("##[endgroup]")
267 case "debug":
268 h.Debug(c.Data)
269 case "warning":
270 m.commandIssue(LevelWarning, name, c)
271 case "error":
272 m.commandIssue(LevelFailure, name, c)
273 case "notice":
274 m.commandIssue(LevelNotice, name, c)
275 case "echo":
276 if c.Data == "" {
277 // ArgUtil.NotNullOrEmpty throws ArgumentNullException.
278 return ghErrorf("Value cannot be null. (Parameter 'value')")
279 }
280 switch strings.ToUpper(strings.TrimSpace(c.Data)) {
281 case "ON":
282 m.echo = true
283 h.Debug("Setting echo command value to 'on'")
284 case "OFF":
285 m.echo = false
286 h.Debug("Setting echo command value to 'off'")
287 default:
288 return ghErrorf("Invalid echo command value. Possible values can be: 'on', 'off'. Current value is: '%s'.", c.Data)
289 }
290 case "set-output":
291 h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
292 n := c.prop("name")
293 if n == "" {
294 return ghErrorf("Required field 'name' is missing in ##[set-output] command.")
295 }
296 h.SetOutput(n, c.Data)
297 h.Debug(fmt.Sprintf("steps.<id>.outputs.%s='%s'", n, c.Data))
298 case "save-state":
299 h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
300 n := c.prop("name")
301 if n == "" {
302 return ghErrorf("Required field 'name' is missing in ##[save-state] command.")
303 }
304 h.SaveState(n, c.Data)
305 h.Debug(fmt.Sprintf("Save intra-action state %s = %s", n, c.Data))
306 case "set-env":
307 if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
308 return ghErrorf(unsupportedCommandMessageDisabled, name)
309 }
310 n := c.prop("name")
311 if n == "" {
312 return ghErrorf("Required field 'name' is missing in ##[set-env] command.")
313 }
314 if b := blockedEnvName(n); b != "" {
315 h.Tagged("##[error]", fmt.Sprintf("Can't update %s environment variable using ::set-env:: command.", b))
316 return nil
317 }
318 h.SetEnv(n, c.Data)
319 h.Debug(fmt.Sprintf("%s='%s'", n, c.Data))
320 case "add-path":
321 if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
322 return ghErrorf(unsupportedCommandMessageDisabled, name)
323 }
324 if c.Data == "" {
325 return ghErrorf("Value cannot be null. (Parameter 'path')")
326 }
327 h.AddPath(c.Data)
328 case "add-mask":
329 m.addMask(c)
330 case "add-matcher":
331 return m.addMatcher(c)
332 case "remove-matcher":
333 return m.removeMatcher(c)
334 }
335 return nil
336}
337
338// warn is IExecutionContext.Warning.
339func (m *commandManager) warn(msg string) { m.host.AddIssue(issue{Level: LevelWarning, Message: msg}) }
340
341// addMask is AddMaskCommandExtension (ActionCommandManager.cs:402-424).
342func (m *commandManager) addMask(c *actionCommand) {
343 if strings.TrimFunc(c.Data, unicode.IsSpace) == "" {
344 m.warn("Can't add secret mask for empty string in ##[add-mask] command.")
345 return
346 }
347 if m.echo {
348 m.host.Out("::add-mask::***")
349 }
350 m.host.AddMask(c.Data)
351 // Also add each individual line. Typically individual lines are processed
352 // from STDOUT of child processes.
353 for _, item := range strings.FieldsFunc(c.Data, func(r rune) bool { return r == '\r' || r == '\n' }) {
354 if item = strings.TrimFunc(item, unicode.IsSpace); item != "" {
355 m.host.AddMask(item)
356 }
357 }
358}
359
360// matcherPath roots a path of add-matcher and remove-matcher in the workspace.
361func (m *commandManager) matcherPath(file string) string {
362 file = m.host.HostPath(file) // ActionCommandManager.cs:513-517, :575-580
363 if !filepath.IsAbs(file) {
364 return filepath.Join(m.host.Workspace(), file)
365 }
366 return file
367}
368
369// loadMatchers is IOUtil.LoadObject<IssueMatchersConfig>.
370func (m *commandManager) loadMatchers(file string) (*matchersConfig, error) {
371 b, err := m.host.ReadMatcherFile(file)
372 if err != nil {
373 return nil, dotnetFileError(file, err)
374 }
375 return parseMatchersConfig(b)
376}
377
378// dotnetFileError gives the error of a file that cannot be read the text of
379// the .NET exception (FileNotFoundException, DirectoryNotFoundException).
380func dotnetFileError(file string, err error) error {
381 switch {
382 case errors.Is(err, fs.ErrNotExist):
383 if st, e := os.Stat(filepath.Dir(file)); e != nil || !st.IsDir() {
384 return ghErrorf("Could not find a part of the path '%s'.", file)
385 }
386 return ghErrorf("Could not find file '%s'.", file)
387 case errors.Is(err, fs.ErrPermission):
388 return ghErrorf("Access to the path '%s' is denied.", file)
389 }
390 return err
391}
392
393// addMatcher is AddMatcherCommandExtension (ActionCommandManager.cs:496-536).
394func (m *commandManager) addMatcher(c *actionCommand) error {
395 if c.Data == "" {
396 m.warn("File path must be specified.")
397 return nil
398 }
399 cfg, err := m.loadMatchers(m.matcherPath(c.Data))
400 if err != nil {
401 return err
402 }
403 if cfg != nil && len(cfg.Matchers) > 0 {
404 if err := cfg.validate(); err != nil {
405 return err
406 }
407 m.host.Debug(m.host.Matchers().add(cfg))
408 }
409 return nil
410}
411
412// removeMatcher is RemoveMatcherCommandExtension (ActionCommandManager.cs:538-607).
413func (m *commandManager) removeMatcher(c *actionCommand) error {
414 owner, file := c.prop("owner"), c.Data
415 if owner != "" && file != "" {
416 m.warn("Either specify an owner name or a file path in ##[remove-matcher] command. Both values cannot be set.")
417 return nil
418 }
419 if owner == "" && file == "" {
420 m.warn("Either an owner name or a file path must be specified in ##[remove-matcher] command.")
421 return nil
422 }
423 if owner != "" {
424 m.host.Debug(m.host.Matchers().remove([]string{owner}))
425 return nil
426 }
427 cfg, err := m.loadMatchers(m.matcherPath(file))
428 if err != nil {
429 return err
430 }
431 if cfg != nil && len(cfg.Matchers) > 0 {
432 var owners []string
433 for _, mc := range cfg.Matchers {
434 owners = append(owners, mc.Owner)
435 }
436 m.host.Debug(m.host.Matchers().remove(owners))
437 }
438 return nil
439}