1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strconv"
10 "strings"
11 "sync"
12 "time"
13 "unicode"
14
15 "github.com/dlclark/regexp2"
16)
17
18// outputManager is OutputManager (Runner.Worker/Handlers/OutputManager.cs): it
19// receives the lines a step prints, runs the workflow commands and the
20// problem matchers on them, and logs what is left. One per process of a step.
21
22const (
23 // maxMatcherAttempts is OutputManager._maxAttempts.
24 maxMatcherAttempts = 3
25 // matcherTimeoutKey is OutputManager._timeoutKey.
26 matcherTimeoutKey = "GITHUB_ACTIONS_RUNNER_ISSUE_MATCHER_TIMEOUT"
27 // repoPathFailsafe is OutputManager._failsafe.
28 repoPathFailsafe = 50
29)
30
31// colorCodeRegex is OutputManager._colorCodeRegex, @"\x0033\[[0-9;]*m?". In
32// .NET "\x00" takes exactly two hex digits, so the pattern is NUL, "33", "["
33// and not ESC: the runner strips nothing from real terminal colors (its own
34// test writes "\033", which C# reads as NUL followed by "33"). Ported as is.
35var colorCodeRegex = regexp2.MustCompile("\x0033\\[[0-9;]*m?", 0)
36
37// colorCodePrefix is OutputManager._colorCodePrefix, "\033[": NUL, "33[".
38const colorCodePrefix = "\x0033["
39
40// parseTimeSpan is TimeSpan.TryParse with the invariant culture: "d" days,
41// "hh:mm", "hh:mm:ss[.fffffff]" and "d.hh:mm:ss[.fffffff]", with an optional
42// leading minus.
43func parseTimeSpan(s string) (time.Duration, bool) {
44 s = strings.TrimSpace(s)
45 if s == "" {
46 return 0, false
47 }
48 neg := strings.HasPrefix(s, "-")
49 s = strings.TrimPrefix(s, "-")
50 var days int64
51 parts := strings.Split(s, ":")
52 if len(parts) == 1 {
53 n, err := strconv.ParseInt(s, 10, 32)
54 if err != nil {
55 return 0, false
56 }
57 d := time.Duration(n) * 24 * time.Hour
58 if neg {
59 d = -d
60 }
61 return d, true
62 }
63 if len(parts) > 3 {
64 return 0, false
65 }
66 if d, rest, ok := strings.Cut(parts[0], "."); ok {
67 n, err := strconv.ParseInt(d, 10, 32)
68 if err != nil {
69 return 0, false
70 }
71 days, parts[0] = n, rest
72 }
73 num := func(x string) (int64, bool) {
74 n, err := strconv.ParseInt(x, 10, 32)
75 return n, err == nil && n >= 0
76 }
77 h, ok := num(parts[0])
78 if !ok {
79 return 0, false
80 }
81 m, ok := num(parts[1])
82 if !ok || m > 59 {
83 return 0, false
84 }
85 var sec float64
86 if len(parts) == 3 {
87 f, err := strconv.ParseFloat(parts[2], 64)
88 if err != nil || f < 0 || f >= 60 || strings.ContainsAny(parts[2], "eE+-") {
89 return 0, false
90 }
91 sec = f
92 }
93 if h > 23 {
94 return 0, false
95 }
96 d := time.Duration(days)*24*time.Hour + time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(sec*float64(time.Second))
97 if neg {
98 d = -d
99 }
100 return d, true
101}
102
103// matcherTimeout is the constructor of OutputManager (:43-60): the variable of
104// the job, then the environment of the runner, then one second. ermite has no
105// job variables; the environment is the executor's own, as the runner's is its
106// worker's.
107func matcherTimeout(getenv func(string) string) time.Duration {
108 if v := getenv(matcherTimeoutKey); v != "" {
109 if d, ok := parseTimeSpan(v); ok && d > 0 {
110 return d
111 }
112 }
113 return time.Second
114}
115
116type outputManager struct {
117 s *stepRun
118 // cancel is closed when the step is cancelled or times out: the matchers
119 // then stop (see stopped). Nil means never.
120 cancel <-chan struct{}
121 root *matcherRoot
122 timeout time.Duration
123
124 mu sync.Mutex
125 matchers []*issueMatcher
126 dirMap map[string]string // directory -> repository path ("" = none)
127 hasDir map[string]bool
128}
129
130// newOutputManager subscribes to the matchers of the job.
131//
132// cancel is the cancellation channel of the step (the one the process gets):
133// once it is closed no matcher runs any more. The runner has no such rule, but
134// its worker waits for the output of a step with the step's cancellation
135// token, and a pile of lines against catastrophic patterns (1 s each, 3
136// attempts, per matcher) would hold a step past its timeout for minutes.
137func newOutputManager(s *stepRun, cancel <-chan struct{}) *outputManager {
138 o := &outputManager{s: s, cancel: cancel, root: s.j.matchers, timeout: matcherTimeout(s.j.getenv), dirMap: map[string]string{}, hasDir: map[string]bool{}}
139 o.mu.Lock()
140 o.root.subscribe(o)
141 for _, c := range o.root.snapshot() {
142 if m, err := newIssueMatcher(c, o.timeout); err == nil {
143 o.matchers = append(o.matchers, m)
144 }
145 }
146 o.mu.Unlock()
147 return o
148}
149
150// errStopped is the result of a match that was abandoned because the step was
151// cancelled.
152var errStopped = msgError("matching stopped: the step was cancelled")
153
154// runMatch is m.match, abandoned when the step is cancelled while it runs: the
155// engine cannot be interrupted, so it goes on in its goroutine until its own
156// timeout, but the step does not wait for it and the matcher is not used again
157// (stopped is true from then on).
158func (o *outputManager) runMatch(m *issueMatcher, line string) (*issueMatch, error) {
159 if o.cancel == nil {
160 return m.match(line)
161 }
162 type result struct {
163 m *issueMatch
164 err error
165 }
166 ch := make(chan result, 1)
167 go func() {
168 r, err := m.match(line)
169 ch <- result{r, err}
170 }()
171 select {
172 case r := <-ch:
173 return r.m, r.err
174 case <-o.cancel:
175 return nil, errStopped
176 }
177}
178
179// stopped reports whether the step was cancelled.
180func (o *outputManager) stopped() bool {
181 if o.cancel == nil {
182 return false
183 }
184 select {
185 case <-o.cancel:
186 return true
187 default:
188 return false
189 }
190}
191
192func (o *outputManager) dispose() { o.root.unsubscribe(o) }
193
194// matcherChanged is OnMatcherChanged: the matcher goes first, the others of
195// the same owner (ignoring case) go. A config without patterns only removes.
196func (o *outputManager) matcherChanged(c *matcherConfig) {
197 o.mu.Lock()
198 defer o.mu.Unlock()
199 var next []*issueMatcher
200 if len(c.Patterns) > 0 {
201 if m, err := newIssueMatcher(c, o.timeout); err == nil {
202 next = append(next, m)
203 }
204 }
205 for _, m := range o.matchers {
206 if !strings.EqualFold(m.owner, c.Owner) {
207 next = append(next, m)
208 }
209 }
210 o.matchers = next
211}
212
213func (o *outputManager) removeMatcher(m *issueMatcher) {
214 o.mu.Lock()
215 defer o.mu.Unlock()
216 var next []*issueMatcher
217 for _, x := range o.matchers {
218 if x != m {
219 next = append(next, x)
220 }
221 }
222 o.matchers = next
223}
224
225// onData is OutputManager.OnDataReceived.
226func (o *outputManager) onData(line string) {
227 s := o.s
228 if s.cmds.fatal != nil {
229 return // the handler threw: ExecuteAsync is over, nothing more is read
230 }
231 // ## commands
232 if line != "" && (strings.Contains(line, "##[") || strings.Contains(line, "::")) {
233 if s.cmds.TryProcess(line) {
234 return
235 }
236 }
237 // Strip runner-controlled markers from user output to prevent injection.
238 if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") {
239 line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`)
240 }
241 // Problem matchers.
242 o.mu.Lock()
243 matchers := append([]*issueMatcher(nil), o.matchers...)
244 o.mu.Unlock()
245 if len(matchers) > 0 && !o.stopped() {
246 stripped := line
247 if strings.Contains(line, colorCodePrefix) {
248 stripped, _ = colorCodeRegex.Replace(line, "", -1, -1)
249 }
250 for _, m := range matchers {
251 var match *issueMatch
252 for attempt := 1; attempt <= maxMatcherAttempts; attempt++ {
253 if o.stopped() {
254 break // the lines that are left are only logged
255 }
256 mt, err := o.runMatch(m, stripped)
257 if err == nil {
258 match = mt
259 break
260 }
261 if o.stopped() {
262 break // a timeout that the cancellation caused counts for nothing
263 }
264 if attempt < maxMatcherAttempts {
265 s.Debug(fmt.Sprintf("Timeout processing issue matcher '%s' against line '%s'. Exception: System.Text.RegularExpressions.RegexMatchTimeoutException: %s", m.owner, stripped, err.Error()))
266 } else {
267 s.j.addIssueFor(s, issue{Level: LevelWarning, Message: fmt.Sprintf("Removing issue matcher '%s'. Matcher failed %d times. Error: %s", m.owner, maxMatcherAttempts, err.Error())})
268 o.removeMatcher(m)
269 }
270 }
271 if match != nil {
272 // Reset other matchers.
273 for _, other := range matchers {
274 if other != m {
275 other.reset()
276 }
277 }
278 if is, ok := o.convertToIssue(match); ok {
279 is.LogText = stripped
280 s.j.addIssueFor(s, is)
281 return
282 }
283 }
284 }
285 }
286 // Regular output.
287 s.Out(line)
288}
289
290// convertToIssue is OutputManager.ConvertToIssue (:196-327).
291func (o *outputManager) convertToIssue(m *issueMatch) (issue, bool) {
292 s := o.s
293 if strings.TrimFunc(m.message.s, unicode.IsSpace) == "" {
294 s.Debug("Skipping logging an issue for the matched line because the message is empty.")
295 return issue{}, false
296 }
297 var level AnnotationLevel
298 switch strings.ToLower(m.severity.s) {
299 case "", "error":
300 level = LevelFailure
301 case "warning":
302 level = LevelWarning
303 case "notice":
304 level = LevelNotice
305 default:
306 s.Debug(fmt.Sprintf("Skipped logging an issue for the matched line because the severity '%s' is not supported.", m.severity.s))
307 return issue{}, false
308 }
309 is := issue{Level: level, Message: m.message.s, Props: map[string]string{}}
310 // unsigned parses like int.TryParse(NumberStyles.None): digits only.
311 unsigned := func(v string) (string, bool) {
312 for _, r := range v {
313 if r < '0' || r > '9' {
314 return "", false
315 }
316 }
317 n, err := strconv.ParseInt(v, 10, 32)
318 if err != nil || v == "" {
319 return "", false
320 }
321 return strconv.FormatInt(n, 10), true
322 }
323 if m.line.s != "" {
324 if n, ok := unsigned(m.line.s); ok {
325 is.Props["line"] = n
326 } else {
327 s.Debug(fmt.Sprintf("Unable to parse line number '%s'", m.line.s))
328 }
329 }
330 if m.column.s != "" {
331 if n, ok := unsigned(m.column.s); ok {
332 is.Props["col"] = n
333 } else {
334 s.Debug(fmt.Sprintf("Unable to parse column number '%s'", m.column.s))
335 }
336 }
337 if c := strings.TrimFunc(m.code.s, unicode.IsSpace); c != "" {
338 is.Props["code"] = c
339 }
340 // File.
341 if strings.TrimFunc(m.file.s, unicode.IsSpace) != "" {
342 file := m.file.s
343 // Root using fromPath.
344 if strings.TrimFunc(m.fromPath.s, unicode.IsSpace) != "" && !filepath.IsAbs(file) {
345 if dir := filepath.Dir(m.fromPath.s); dir != "." && dir != "" {
346 file = filepath.Join(dir, file)
347 }
348 }
349 // Root using workspace; an absolute path is the container's and is
350 // translated to the host's (OutputManager.cs:281-311).
351 if !filepath.IsAbs(file) {
352 file = filepath.Join(s.Workspace(), file)
353 } else {
354 file = filepath.Clean(file)
355 file = filepath.Clean(s.HostPath(file))
356 }
357 file = filepath.Clean(file)
358 if st, err := os.Stat(file); err == nil && !st.IsDir() {
359 if repo := o.repositoryPath(file, 0); repo != "" {
360 rel := strings.TrimLeft(file[len(repo):], "/")
361 is.Props["file"] = rel
362 } else {
363 s.Debug(fmt.Sprintf("Dropping file value '%s'. Path is not under the workflow repo.", file))
364 }
365 } else {
366 s.Debug(fmt.Sprintf("Dropping file value '%s'. Path does not exist", file))
367 }
368 }
369 return is, true
370}
371
372// repositoryPath is OutputManager.GetRepositoryPath: the closest directory
373// above filePath that has a .git/config naming the repository of the job.
374func (o *outputManager) repositoryPath(filePath string, recursion int) string {
375 if len(o.dirMap) > 100 {
376 o.dirMap, o.hasDir = map[string]string{}, map[string]bool{}
377 }
378 dir := filepath.Dir(filePath)
379 if dir == filePath || dir == "." || recursion > repoPathFailsafe {
380 return ""
381 }
382 if o.hasDir[dir] {
383 return o.dirMap[dir]
384 }
385 repo := ""
386 gitConfig := filepath.Join(dir, ".git", "config")
387 if st, err := os.Stat(gitConfig); err == nil && !st.IsDir() {
388 serverURL := o.s.j.githubValue("server_url")
389 if serverURL == "" {
390 serverURL = "https://github.com"
391 }
392 host := serverURL
393 if i := strings.Index(host, "://"); i >= 0 {
394 host = host[i+3:]
395 }
396 if i := strings.IndexAny(host, "/:"); i >= 0 {
397 host = host[:i]
398 }
399 nameWithOwner := o.s.j.githubValue("repository")
400 patterns := []string{"url = " + serverURL + "/" + nameWithOwner, "url = git@" + host + ":" + nameWithOwner + ".git"}
401 if b, err := os.ReadFile(gitConfig); err == nil {
402 for _, l := range strings.Split(string(b), "\n") {
403 l = strings.TrimFunc(l, unicode.IsSpace)
404 for _, p := range patterns {
405 if strings.EqualFold(l, p) {
406 repo = dir
407 }
408 }
409 }
410 } else {
411 o.s.Debug(fmt.Sprintf("Error when attempting to determine whether the path '%s' is under the workflow repository: %v", filePath, err))
412 }
413 } else {
414 repo = o.repositoryPath(dir, recursion+1)
415 }
416 o.dirMap[dir], o.hasDir[dir] = repo, true
417 return repo
418}