1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "fmt"
7 "strings"
8 "unicode"
9)
10
11// Workflow commands in the output of a step: a port of ActionCommand.cs
12// (Runner.Common) and of the parts of ActionCommandManager.cs that CI-1 needs.
13// Masking (add-mask) and problem matchers are CI-2; they are announced in the
14// log and otherwise ignored.
15
16type escapeMapping struct{ token, replacement string }
17
18// _escapeMappings, _escapeDataMappings, _escapePropertyMappings of
19// ActionCommand.cs:11-35, in order: the replacements run one after the other.
20var (
21 escapeMappings = []escapeMapping{{";", "%3B"}, {"\r", "%0D"}, {"\n", "%0A"}, {"]", "%5D"}, {"%", "%25"}}
22 escapeData = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {"%", "%25"}}
23 escapeProperty = []escapeMapping{{"\r", "%0D"}, {"\n", "%0A"}, {":", "%3A"}, {",", "%2C"}, {"%", "%25"}}
24)
25
26func unescape(s string, maps []escapeMapping) string {
27 for _, m := range maps {
28 s = strings.ReplaceAll(s, m.replacement, m.token)
29 }
30 return s
31}
32
33// actionCommand is a parsed workflow command.
34type actionCommand struct {
35 Name string
36 Props map[string]string // keys compared case-insensitively (lower-cased)
37 Data string
38}
39
40func (c *actionCommand) prop(name string) string { return c.Props[strings.ToLower(name)] }
41
42const commandKey = "::"
43
44// parseCommandV2 is ActionCommand.TryParseV2 (ActionCommand.cs:57): "::name
45// key=value,key=value::data".
46func parseCommandV2(message string, registered func(string) bool) (*actionCommand, bool) {
47 // .NET TrimStart() trims Unicode white space.
48 message = strings.TrimLeftFunc(message, unicode.IsSpace)
49 if !strings.HasPrefix(message, commandKey) {
50 return nil, false
51 }
52 end := strings.Index(message[len(commandKey):], commandKey)
53 if end < 0 {
54 return nil, false
55 }
56 end += len(commandKey)
57 info := message[len(commandKey):end]
58 name, rest, hasSpace := strings.Cut(info, " ")
59 if !registered(name) {
60 return nil, false
61 }
62 cmd := &actionCommand{Name: name, Props: map[string]string{}}
63 if hasSpace && len(info) > len(name) { // spaceIndex > 0
64 for _, p := range strings.Split(strings.TrimSpace(rest), ",") {
65 if p == "" {
66 continue
67 }
68 k, v, ok := strings.Cut(p, "=")
69 if ok && k != "" && v != "" { // Split(RemoveEmptyEntries): both parts non-empty
70 cmd.Props[strings.ToLower(k)] = unescape(v, escapeProperty)
71 }
72 }
73 }
74 cmd.Data = unescape(message[end+len(commandKey):], escapeData)
75 return cmd, true
76}
77
78// parseCommandV1 is ActionCommand.TryParse (ActionCommand.cs:127): the legacy
79// "##[name key=value;key=value]data".
80func parseCommandV1(message string, registered func(string) bool) (*actionCommand, bool) {
81 const prefix = "##["
82 pi := strings.Index(message, prefix)
83 if pi < 0 {
84 return nil, false
85 }
86 rb := strings.IndexByte(message[pi:], ']')
87 if rb < 0 {
88 return nil, false
89 }
90 rb += pi
91 info := message[pi+len(prefix) : rb]
92 name, rest, hasSpace := strings.Cut(info, " ")
93 if !registered(name) {
94 return nil, false
95 }
96 cmd := &actionCommand{Name: name, Props: map[string]string{}}
97 if hasSpace && len(info) > len(name) {
98 for _, p := range strings.Split(rest, ";") {
99 if p == "" {
100 continue
101 }
102 k, v, ok := strings.Cut(p, "=")
103 if ok && k != "" && v != "" {
104 cmd.Props[strings.ToLower(k)] = unescape(v, escapeMappings)
105 }
106 }
107 }
108 cmd.Data = unescape(message[rb+1:], escapeMappings)
109 return cmd, true
110}
111
112// Messages of Constants.cs:234-236.
113const (
114 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/"
115 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/"
116 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`."
117)
118
119// commandNames are the commands the executor registers, in ActionCommandManager
120// (the names of the extensions at ActionCommandManager.cs). add-mask,
121// add-matcher and remove-matcher are recognised and reported as not supported
122// yet.
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}
143
144// commandManager is ActionCommandManager: one per step.
145type commandManager struct {
146 host commandHost
147 registered map[string]bool
148 stopped bool
149 stopToken string
150 echo bool
151}
152
153func newCommandManager(h commandHost, echo bool) *commandManager {
154 m := &commandManager{host: h, registered: map[string]bool{}, echo: echo}
155 for _, n := range commandNames {
156 m.registered[strings.ToLower(n)] = true
157 }
158 return m
159}
160
161func (m *commandManager) isRegistered(name string) bool { return m.registered[strings.ToLower(name)] }
162
163// TryProcess handles one line of output. It returns true when the line was a
164// command and must not be logged as plain text (ActionCommandManager.cs:61).
165func (m *commandManager) TryProcess(input string) bool {
166 if input == "" {
167 return false
168 }
169 cmd, ok := parseCommandV2(input, m.isRegistered)
170 if !ok {
171 cmd, ok = parseCommandV1(input, m.isRegistered)
172 }
173 if !ok {
174 return false
175 }
176 name := strings.ToLower(cmd.Name)
177 if m.stopped {
178 if m.stopToken != "" && strings.EqualFold(cmd.Name, m.stopToken) {
179 m.host.Out(input)
180 m.host.Debug("Resume processing commands")
181 delete(m.registered, strings.ToLower(m.stopToken))
182 m.stopped, m.stopToken = false, ""
183 return true
184 }
185 m.host.Debug(fmt.Sprintf("Process commands has been stopped and waiting for '##[%s]' to resume.", m.stopToken))
186 return false
187 }
188 if name == "stop-commands" {
189 if err := m.validateStopToken(cmd.Data); err != nil {
190 m.fail(input, false, err)
191 return true
192 }
193 m.stopToken, m.stopped = cmd.Data, true
194 m.registered[strings.ToLower(cmd.Data)] = true
195 m.host.Out(input)
196 m.host.Debug("Paused processing commands until the token you called ::stopCommands:: with is received")
197 return true
198 }
199 omitEcho := name == "debug" || name == "warning" || name == "error" || name == "notice" || name == "add-mask"
200 if m.echo && !omitEcho {
201 m.host.Out(input)
202 }
203 if err := m.run(name, cmd); err != nil {
204 m.fail(input, omitEcho, err)
205 }
206 return true
207}
208
209func (m *commandManager) fail(input string, omitEcho bool, err error) {
210 info := input
211 if omitEcho {
212 info = ""
213 }
214 m.host.Tagged("##[error]", fmt.Sprintf("Unable to process command '%s' successfully.", info))
215 m.host.Tagged("##[error]", err.Error())
216 m.host.CommandFailed()
217}
218
219func (m *commandManager) validateStopToken(token string) error {
220 invalid := m.isRegistered(token) || token == "" || strings.EqualFold(token, "pause-logging")
221 if invalid && !m.host.EnvFlag("ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS") {
222 return msgError(unsupportedStopTokenDisabled)
223 }
224 return nil
225}
226
227func (m *commandManager) run(name string, c *actionCommand) error {
228 h := m.host
229 switch name {
230 case "group":
231 h.Out("##[group]" + c.Data) // GroupingCommandExtension, ActionCommandManager.cs:802
232 case "endgroup":
233 h.Out("##[endgroup]")
234 case "debug":
235 h.Debug(c.Data)
236 case "warning", "error", "notice":
237 // IssueCommandExtension: the annotation is logged; ermite does not store
238 // annotations yet (CI-2), so file, line and title are dropped.
239 h.Tagged("##["+name+"]", c.Data)
240 case "echo":
241 if c.Data == "" {
242 return ghErrorf("Value cannot be null or empty. (Parameter 'value')")
243 }
244 switch strings.ToUpper(strings.TrimSpace(c.Data)) {
245 case "ON":
246 m.echo = true
247 h.Debug("Setting echo command value to 'on'")
248 case "OFF":
249 m.echo = false
250 h.Debug("Setting echo command value to 'off'")
251 default:
252 return ghErrorf("Invalid echo command value. Possible values can be: 'on', 'off'. Current value is: '%s'.", c.Data)
253 }
254 case "set-output":
255 h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
256 n := c.prop("name")
257 if n == "" {
258 return ghErrorf("Required field 'name' is missing in ##[set-output] command.")
259 }
260 h.SetOutput(n, c.Data)
261 h.Debug(fmt.Sprintf("steps.<id>.outputs.%s='%s'", n, c.Data))
262 case "save-state":
263 h.Tagged("##[warning]", fmt.Sprintf(unsupportedCommandMessage, name))
264 n := c.prop("name")
265 if n == "" {
266 return ghErrorf("Required field 'name' is missing in ##[save-state] command.")
267 }
268 h.SaveState(n, c.Data)
269 h.Debug(fmt.Sprintf("Save intra-action state %s = %s", n, c.Data))
270 case "set-env":
271 if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
272 return ghErrorf(unsupportedCommandMessageDisabled, name)
273 }
274 n := c.prop("name")
275 if n == "" {
276 return ghErrorf("Required field 'name' is missing in ##[set-env] command.")
277 }
278 if b := blockedEnvName(n); b != "" {
279 h.Tagged("##[error]", fmt.Sprintf("Can't update %s environment variable using ::set-env:: command.", b))
280 return nil
281 }
282 h.SetEnv(n, c.Data)
283 h.Debug(fmt.Sprintf("%s='%s'", n, c.Data))
284 case "add-path":
285 if !h.EnvFlag("ACTIONS_ALLOW_UNSECURE_COMMANDS") {
286 return ghErrorf(unsupportedCommandMessageDisabled, name)
287 }
288 if c.Data == "" {
289 return ghErrorf("Value cannot be null or empty. (Parameter 'path')")
290 }
291 h.AddPath(c.Data)
292 case "add-mask", "add-matcher", "remove-matcher":
293 // Not supported yet (CI-2). The value of add-mask is not echoed.
294 h.Tagged("##[warning]", fmt.Sprintf("The `%s` command is not supported yet by this runner.", name))
295 }
296 return nil
297}