detached 527f9eb

feat(executor): implement hashFiles()

A port of the runner's hashFiles script (@actions/glob and minimatch with its
options): patterns relative to the workspace, '!' negations, several patterns,
'--follow-symbolic-links', implicit descendants, the sorted depth first walk.
The result is the SHA-256 of the concatenated SHA-256 digests of the files,
or '' when none matches. Recorded values (expr-hashfiles) are pinned in tests,
and a differential test against the official script (opt in with
ERMITE_HASHFILES_JS) covers 40 pattern sets with and without symbolic links.

The executor runs in the sandbox where the workspace is, so the function is
evaluated there and no host side helper is needed. Files are opened through an
os.Root on the workspace: a link that leaves it is not followed, which the
official script would do.
commit
527f9eb97a08f801e393544eb8ec0c2cf7dd68de
author
brice · · 2026-10-13 03:42 UTC
parent
e88c60f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +917 -7
A internal/executor/hashfiles.go +749 -0
@@ -0,0 +1,749 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "encoding/hex"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "io/fs"
13+ "os"
14+ "path"
15+ "regexp"
16+ "sort"
17+ "strings"
18+ "time"
19+
20+ "bnema.dev/ermite-actions/expr"
21+)
22+
23+// hashFiles is the function of the same name (Runner.Worker/Expressions/
24+// HashFilesFunction.cs). The official runner starts node on its hashFiles
25+// script (Misc/expressionFunc/hashFiles, which uses @actions/glob); this is a
26+// port of that script, evaluated by the executor, which runs in the sandbox
27+// next to the workspace.
28+//
29+// For each file that matches the patterns, in the order of @actions/glob's
30+// traversal (a depth first walk with the names of a directory sorted), the
31+// SHA-256 of the file is written to a second SHA-256; the result is its
32+// hexadecimal digest, or "" when no file matched.
33+//
34+// Differences from the official function, on purpose: the files are read
35+// through an os.Root on the workspace, so that a symbolic link that leaves the
36+// workspace is not followed (the executor can be root, the workspace belongs
37+// to the user of the steps) and its file is skipped; and only regular files
38+// are hashed (a FIFO would block until the timeout). A matched symbolic link
39+// whose target is missing fails the call, as it does in the script. Braces ({a,b}), extglobs (+(a|b)) and a leading
40+// "!" in the middle of a pattern are not special, as in @actions/glob.
41+const hashFilesTimeout = 120 * time.Second
42+
43+func (j *job) hashFilesFunc() expr.Func {
44+ return func(ctx context.Context, args []expr.Value) (expr.Value, error) {
45+ var patterns []string
46+ follow := false
47+ for i, a := range args {
48+ p := expr.CoerceString(a)
49+ if i == 0 && strings.HasPrefix(p, "--") {
50+ if strings.EqualFold(p, "--follow-symbolic-links") {
51+ follow = true
52+ continue
53+ }
54+ // new ArgumentOutOfRangeException(message) takes the text as the parameter name.
55+ //nolint:staticcheck,misspell // GitHub's text, typo included
56+ return nil, fmt.Errorf("Specified argument was out of the range of valid values. (Parameter 'Invalid glob option %s, avaliable option: '--follow-symbolic-links'.')", p)
57+ }
58+ patterns = append(patterns, p)
59+ }
60+ ws := j.state.Layout().Workspace
61+ ctx, cancel := context.WithTimeout(ctx, hashFilesTimeout)
62+ defer cancel()
63+ sum, err := hashFilesIn(ctx, ws, j.state.Layout().Home, patterns, follow)
64+ desc := strings.ReplaceAll(strings.Join(patterns, ", "), "'", "''")
65+ switch {
66+ case errors.Is(err, context.DeadlineExceeded):
67+ //nolint:staticcheck // GitHub's text, with its final period
68+ return nil, fmt.Errorf("hashFiles('%s') couldn't finish within %d seconds.", desc, int(hashFilesTimeout/time.Second))
69+ case err != nil:
70+ j.cfg.Log.Debug().Err(err).Msg("hashFiles failed")
71+ return nil, fmt.Errorf("hashFiles('%s') failed. Fail to hash files under directory '%s'", desc, ws)
72+ }
73+ return expr.String(sum), nil
74+ }
75+}
76+
77+// ---------------------------------------------------------------- patterns
78+
79+// seg is a segment of a pattern: its literal value (getLiteral, "" when it has
80+// a wildcard) and its minimatch form.
81+type seg struct {
82+ raw string // the segment as written, for the "**" test
83+ lit string
84+ mm mmSeg
85+ root bool
86+}
87+
88+// mmSeg is a parsed segment: the globstar, a literal, or a regular expression.
89+type mmSeg struct {
90+ globstar bool
91+ lit string
92+ re *regexp.Regexp
93+}
94+
95+type globPattern struct {
96+ negate bool
97+ segs []seg
98+ trailingSep bool
99+ implicit bool
100+ searchPath string
101+}
102+
103+const (
104+ kindNone = 0
105+ kindDir = 1
106+ kindFile = 2
107+ kindAll = 3
108+)
109+
110+func splitSlash(s string) []string {
111+ return regexp.MustCompile(`/+`).Split(s, -1)
112+}
113+
114+var errPattern = errors.New("invalid pattern")
115+
116+// newGlobPattern is Pattern's constructor for a pattern written by the user,
117+// relative to cwd (the workspace, as segments).
118+func newGlobPattern(text, cwd, home string) (*globPattern, error) {
119+ p := &globPattern{}
120+ text = strings.TrimSpace(text)
121+ for strings.HasPrefix(text, "!") {
122+ p.negate = !p.negate
123+ text = strings.TrimSpace(text[1:])
124+ }
125+ if text == "" {
126+ return nil, fmt.Errorf("%w: pattern cannot be empty", errPattern)
127+ }
128+ norm := regexp.MustCompile(`//+`).ReplaceAllString(text, "/")
129+ p.trailingSep = strings.HasSuffix(norm, "/")
130+ trimmed := norm
131+ if trimmed != "/" {
132+ trimmed = strings.TrimSuffix(trimmed, "/")
133+ }
134+ rooted := strings.HasPrefix(trimmed, "/")
135+ var rawSegs []string
136+ if rooted {
137+ rawSegs = append([]string{"/"}, splitNonEmpty(trimmed)...)
138+ } else {
139+ rawSegs = strings.Split(trimmed, "/")
140+ }
141+ for i, s := range rawSegs {
142+ l := getLiteral(s)
143+ if (l == "." && i != 0) || l == ".." {
144+ return nil, fmt.Errorf("%w: relative pathing '.' and '..' is not allowed: %s", errPattern, text)
145+ }
146+ }
147+ var prefix []seg
148+ rest := rawSegs
149+ switch {
150+ case trimmed == "." || strings.HasPrefix(trimmed, "./"):
151+ prefix, rest = literalSegs(cwd), rawSegs[1:]
152+ case trimmed == "~" || strings.HasPrefix(trimmed, "~/"):
153+ prefix, rest = literalSegs(home), rawSegs[1:]
154+ case rooted:
155+ rest = rawSegs[1:]
156+ prefix = []seg{{raw: "/", lit: "/", mm: mmSeg{lit: ""}, root: true}}
157+ default:
158+ prefix = literalSegs(cwd)
159+ }
160+ p.segs = prefix
161+ for _, r := range rest {
162+ p.segs = append(p.segs, seg{raw: r, lit: getLiteral(r), mm: parseMM(r)})
163+ }
164+ p.computeSearchPath()
165+ return p, nil
166+}
167+
168+func splitNonEmpty(s string) []string {
169+ var out []string
170+ for _, x := range strings.Split(s, "/") {
171+ if x != "" {
172+ out = append(out, x)
173+ }
174+ }
175+ return out
176+}
177+
178+// literalSegs is the absolute path abs as literal segments.
179+func literalSegs(abs string) []seg {
180+ out := []seg{{raw: "/", lit: "/", mm: mmSeg{lit: ""}, root: true}}
181+ for _, s := range splitNonEmpty(abs) {
182+ out = append(out, seg{raw: "", lit: s, mm: mmSeg{lit: s}})
183+ }
184+ return out
185+}
186+
187+func (p *globPattern) computeSearchPath() {
188+ var parts []string
189+ for _, s := range p.segs {
190+ if s.lit == "" {
191+ break
192+ }
193+ parts = append(parts, s.lit)
194+ }
195+ p.searchPath = joinSegs(parts)
196+}
197+
198+func joinSegs(parts []string) string {
199+ if len(parts) == 0 {
200+ return ""
201+ }
202+ out := parts[0]
203+ for _, s := range parts[1:] {
204+ if !strings.HasSuffix(out, "/") {
205+ out += "/"
206+ }
207+ out += s
208+ }
209+ return out
210+}
211+
212+func (p *globPattern) withDescendants() *globPattern {
213+ q := *p
214+ q.segs = append(append([]seg(nil), p.segs...), seg{raw: "**", lit: "", mm: mmSeg{globstar: true}})
215+ q.trailingSep = false
216+ q.implicit = true
217+ q.computeSearchPath()
218+ return &q
219+}
220+
221+// getLiteral is Pattern.getLiteral: the segment with its escapes removed, or ""
222+// when it has a wildcard.
223+func getLiteral(s string) string {
224+ r := []rune(s)
225+ var lit []rune
226+ for i := 0; i < len(r); i++ {
227+ c := r[i]
228+ switch {
229+ case c == '\\' && i+1 < len(r):
230+ i++
231+ lit = append(lit, r[i])
232+ case c == '*' || c == '?':
233+ return ""
234+ case c == '[' && i+1 < len(r):
235+ var set []rune
236+ closed := -1
237+ for i2 := i + 1; i2 < len(r); i2++ {
238+ c2 := r[i2]
239+ if c2 == '\\' && i2+1 < len(r) {
240+ i2++
241+ set = append(set, r[i2])
242+ continue
243+ }
244+ if c2 == ']' {
245+ closed = i2
246+ break
247+ }
248+ set = append(set, c2)
249+ }
250+ if closed >= 0 {
251+ if len(set) > 1 {
252+ return ""
253+ }
254+ if len(set) == 1 {
255+ lit = append(lit, set...)
256+ i = closed
257+ continue
258+ }
259+ }
260+ lit = append(lit, c)
261+ default:
262+ lit = append(lit, c)
263+ }
264+ }
265+ return string(lit)
266+}
267+
268+const reSpecials = "().*{}+?[]^$\\!"
269+
270+// parseMM is minimatch's parse for one segment with the options of
271+// @actions/glob (dot, nobrace, noext, nonegate, nocomment).
272+func parseMM(s string) mmSeg {
273+ if s == "**" {
274+ return mmSeg{globstar: true}
275+ }
276+ if s == "" {
277+ return mmSeg{lit: ""}
278+ }
279+ re, magic := translateMM([]rune(s))
280+ if !magic {
281+ return mmSeg{lit: globUnescape(s)}
282+ }
283+ rx, err := regexp.Compile("^" + re + "$")
284+ if err != nil {
285+ return mmSeg{re: regexp.MustCompile(`$.`)}
286+ }
287+ return mmSeg{re: rx}
288+}
289+
290+func globUnescape(s string) string {
291+ r := []rune(s)
292+ var out []rune
293+ for i := 0; i < len(r); i++ {
294+ if r[i] == '\\' && i+1 < len(r) {
295+ i++
296+ }
297+ out = append(out, r[i])
298+ }
299+ return string(out)
300+}
301+
302+func translateMM(p []rune) (re string, magic bool) {
303+ var sb strings.Builder
304+ escaping, inClass := false, false
305+ classStart, reClassStart := -1, -1
306+ for i := 0; i < len(p); i++ {
307+ c := p[i]
308+ if escaping && strings.ContainsRune(reSpecials, c) {
309+ sb.WriteString(`\` + string(c))
310+ escaping = false
311+ continue
312+ }
313+ switch c {
314+ case '\\':
315+ escaping = true
316+ case '?', '*', '+', '@', '!':
317+ if inClass {
318+ if c == '!' && i == classStart+1 {
319+ c = '^'
320+ }
321+ sb.WriteRune(c)
322+ continue
323+ }
324+ switch c {
325+ case '*':
326+ sb.WriteString(`[^/]*?`)
327+ magic = true
328+ case '?':
329+ sb.WriteString(`[^/]`)
330+ magic = true
331+ default:
332+ sb.WriteString(`\` + string(c))
333+ }
334+ case '(':
335+ if inClass {
336+ sb.WriteRune('(')
337+ } else {
338+ sb.WriteString(`\(`)
339+ }
340+ case ')':
341+ sb.WriteString(`\)`)
342+ case '|':
343+ sb.WriteString(`\|`)
344+ escaping = false
345+ case '[':
346+ if inClass {
347+ sb.WriteString(`\[`)
348+ continue
349+ }
350+ inClass, classStart, reClassStart = true, i, sb.Len()
351+ sb.WriteRune('[')
352+ case ']':
353+ if i == classStart+1 || !inClass {
354+ sb.WriteString(`\]`)
355+ escaping = false
356+ continue
357+ }
358+ cs := string(p[classStart+1 : i])
359+ if _, err := regexp.Compile("[" + cs + "]"); err != nil {
360+ sub, m := translateMM([]rune(cs))
361+ cur := sb.String()
362+ sb.Reset()
363+ sb.WriteString(cur[:reClassStart] + `\[` + sub + `\]`)
364+ magic = magic || m
365+ inClass = false
366+ continue
367+ }
368+ magic = true
369+ inClass = false
370+ sb.WriteRune(']')
371+ default:
372+ if escaping {
373+ escaping = false
374+ } else if strings.ContainsRune(reSpecials, c) && (c != '^' || !inClass) {
375+ sb.WriteRune('\\')
376+ }
377+ sb.WriteRune(c)
378+ }
379+ }
380+ if inClass {
381+ cs := string(p[classStart+1:])
382+ sub, m := translateMM([]rune(cs))
383+ cur := sb.String()
384+ sb.Reset()
385+ sb.WriteString(cur[:reClassStart] + `\[` + sub)
386+ magic = magic || m
387+ }
388+ if escaping {
389+ sb.WriteString(`\\`)
390+ }
391+ return sb.String(), magic
392+}
393+
394+// match is Pattern.match.
395+func (p *globPattern) match(item string) int {
396+ if p.segs[len(p.segs)-1].raw == "**" {
397+ item = regexp.MustCompile(`//+`).ReplaceAllString(item, "/")
398+ if !strings.HasSuffix(item, "/") && !p.implicit {
399+ item += "/"
400+ }
401+ } else {
402+ item = trimTrailingSep(item)
403+ }
404+ if p.matchOne(splitSlash(item), p.mmSegs(), 0, 0, false) {
405+ if p.trailingSep {
406+ return kindDir
407+ }
408+ return kindAll
409+ }
410+ return kindNone
411+}
412+
413+func (p *globPattern) mmSegs() []mmSeg {
414+ out := make([]mmSeg, len(p.segs))
415+ for i, s := range p.segs {
416+ out[i] = s.mm
417+ }
418+ return out
419+}
420+
421+// partialMatch is Pattern.partialMatch.
422+func (p *globPattern) partialMatch(item string) bool {
423+ item = trimTrailingSep(item)
424+ if path.Dir(item) == item {
425+ return true // the root matches the root of every pattern
426+ }
427+ return p.matchOne(splitSlash(item), p.mmSegs(), 0, 0, true)
428+}
429+
430+func trimTrailingSep(s string) string {
431+ s = regexp.MustCompile(`//+`).ReplaceAllString(s, "/")
432+ if s != "/" && strings.HasSuffix(s, "/") {
433+ return s[:len(s)-1]
434+ }
435+ return s
436+}
437+
438+func isDotName(s string) bool { return s == "." || s == ".." }
439+
440+// matchOne is Minimatch.matchOne with the dot option set.
441+func (p *globPattern) matchOne(file []string, pat []mmSeg, fi, pi int, partial bool) bool {
442+ fl, pl := len(file), len(pat)
443+ for ; fi < fl && pi < pl; fi, pi = fi+1, pi+1 {
444+ ps, f := pat[pi], file[fi]
445+ if ps.globstar {
446+ fr, pr := fi, pi+1
447+ if pr == pl {
448+ for ; fi < fl; fi++ {
449+ if isDotName(file[fi]) {
450+ return false
451+ }
452+ }
453+ return true
454+ }
455+ for fr < fl {
456+ if p.matchOne(file, pat, fr, pr, partial) {
457+ return true
458+ }
459+ if isDotName(file[fr]) {
460+ break
461+ }
462+ fr++
463+ }
464+ if partial && fr == fl {
465+ return true
466+ }
467+ return false
468+ }
469+ var hit bool
470+ if ps.re != nil {
471+ hit = ps.re.MatchString(f)
472+ } else {
473+ hit = f == ps.lit
474+ }
475+ if !hit {
476+ return false
477+ }
478+ }
479+ switch {
480+ case fi == fl && pi == pl:
481+ return true
482+ case fi == fl:
483+ return partial
484+ default:
485+ return fi == fl-1 && file[fi] == ""
486+ }
487+}
488+
489+// ---------------------------------------------------------------- walk
490+
491+func matchAll(patterns []*globPattern, item string) int {
492+ r := kindNone
493+ for _, p := range patterns {
494+ if p.negate {
495+ r &^= p.match(item)
496+ } else {
497+ r |= p.match(item)
498+ }
499+ }
500+ return r
501+}
502+
503+// getSearchPaths is internal-pattern-helper's getSearchPaths.
504+func getSearchPaths(patterns []*globPattern) []string {
505+ var pos []*globPattern
506+ for _, p := range patterns {
507+ if !p.negate {
508+ pos = append(pos, p)
509+ }
510+ }
511+ state := map[string]string{}
512+ for _, p := range pos {
513+ state[p.searchPath] = "candidate"
514+ }
515+ var out []string
516+ for _, p := range pos {
517+ if state[p.searchPath] == "included" {
518+ continue
519+ }
520+ found := false
521+ key := p.searchPath
522+ for parent := path.Dir(key); parent != key; parent = path.Dir(key) {
523+ if state[parent] != "" {
524+ found = true
525+ break
526+ }
527+ key = parent
528+ }
529+ if !found {
530+ out = append(out, p.searchPath)
531+ state[p.searchPath] = "included"
532+ }
533+ }
534+ return out
535+}
536+
537+type searchItem struct {
538+ path string
539+ level int
540+}
541+
542+// hashFilesIn is the hashFiles script: the hex SHA-256 of the SHA-256 digests
543+// of the files under ws that match the patterns, "" when none does.
544+func hashFilesIn(ctx context.Context, ws, home string, lines []string, follow bool) (string, error) {
545+ cwd := path.Clean(ws)
546+ var patterns []*globPattern
547+ for _, line := range strings.Split(strings.Join(lines, "\n"), "\n") {
548+ line = strings.TrimSpace(line)
549+ if line == "" || strings.HasPrefix(line, "#") {
550+ continue
551+ }
552+ p, err := newGlobPattern(line, cwd, home)
553+ if err != nil {
554+ return "", err
555+ }
556+ patterns = append(patterns, p)
557+ }
558+ all := make([]*globPattern, 0, 2*len(patterns))
559+ for _, p := range patterns {
560+ all = append(all, p)
561+ if p.trailingSep || p.segs[len(p.segs)-1].raw != "**" {
562+ all = append(all, p.withDescendants())
563+ }
564+ }
565+ root, err := os.OpenRoot(cwd)
566+ if err != nil {
567+ if errors.Is(err, fs.ErrNotExist) {
568+ return "", nil
569+ }
570+ return "", err
571+ }
572+ defer func() { _ = root.Close() }()
573+ rel := func(abs string) (string, bool) {
574+ if abs == cwd {
575+ return ".", true
576+ }
577+ if strings.HasPrefix(abs, cwd+"/") {
578+ return abs[len(cwd)+1:], true
579+ }
580+ return "", false
581+ }
582+
583+ var stack []searchItem
584+ for _, sp := range getSearchPaths(all) {
585+ if _, ok := rel(sp); !ok {
586+ if !strings.HasPrefix(cwd+"/", strings.TrimSuffix(sp, "/")+"/") {
587+ continue // neither under the workspace nor above it
588+ }
589+ sp = cwd
590+ }
591+ r, _ := rel(sp)
592+ if _, err := root.Lstat(r); err != nil {
593+ if errors.Is(err, fs.ErrNotExist) {
594+ continue
595+ }
596+ return "", err
597+ }
598+ stack = append([]searchItem{{sp, 1}}, stack...)
599+ }
600+
601+ total := sha256.New()
602+ hashed := 0
603+ var chain []fs.FileInfo
604+ for len(stack) > 0 {
605+ if err := ctx.Err(); err != nil {
606+ return "", err
607+ }
608+ item := stack[len(stack)-1]
609+ stack = stack[:len(stack)-1]
610+ match := matchAll(all, item.path)
611+ partial := match != kindNone
612+ if !partial {
613+ for _, p := range all {
614+ if !p.negate && p.partialMatch(item.path) {
615+ partial = true
616+ break
617+ }
618+ }
619+ }
620+ if !partial {
621+ continue
622+ }
623+ r, under := rel(item.path)
624+ if !under {
625+ // Above the workspace (an absolute pattern): only the way down matters.
626+ r = "."
627+ if item.path != "/" && !strings.HasPrefix(cwd+"/", item.path+"/") {
628+ continue
629+ }
630+ }
631+ var st fs.FileInfo
632+ if follow {
633+ st, err = root.Stat(r)
634+ } else {
635+ st, err = root.Lstat(r)
636+ }
637+ if err != nil {
638+ if errors.Is(err, fs.ErrNotExist) || follow {
639+ continue // vanished, or a broken or escaping link
640+ }
641+ return "", err
642+ }
643+ if st.IsDir() && follow {
644+ for len(chain) >= item.level {
645+ chain = chain[:len(chain)-1]
646+ }
647+ cycle := false
648+ for _, c := range chain {
649+ if os.SameFile(c, st) {
650+ cycle = true
651+ }
652+ }
653+ if cycle {
654+ continue
655+ }
656+ chain = append(chain, st)
657+ }
658+ if st.IsDir() {
659+ if match&kindDir == 0 && !partial {
660+ continue
661+ }
662+ names, err := readDirNames(root, r)
663+ if err != nil {
664+ return "", err
665+ }
666+ base := item.path
667+ for i := len(names) - 1; i >= 0; i-- {
668+ stack = append(stack, searchItem{path.Join(base, names[i]), item.level + 1})
669+ }
670+ continue // directories are skipped by hashFiles
671+ }
672+ if match&kindFile == 0 || !under {
673+ continue
674+ }
675+ if !st.Mode().IsRegular() && !follow && st.Mode()&fs.ModeSymlink == 0 {
676+ continue
677+ }
678+ sum, ok, err := hashOne(ctx, root, r)
679+ if err != nil {
680+ return "", err
681+ }
682+ if !ok {
683+ if st.Mode()&fs.ModeSymlink != 0 {
684+ if _, serr := root.Stat(r); errors.Is(serr, fs.ErrNotExist) {
685+ // The script stats the link's target and fails when it is
686+ // missing (fs.statSync throws ENOENT): so does the function.
687+ return "", fmt.Errorf("broken symbolic link %s: %w", item.path, serr)
688+ }
689+ }
690+ continue
691+ }
692+ total.Write(sum)
693+ hashed++
694+ }
695+ if hashed == 0 {
696+ return "", nil
697+ }
698+ return hex.EncodeToString(total.Sum(nil)), nil
699+}
700+
701+func readDirNames(root *os.Root, rel string) ([]string, error) {
702+ d, err := root.Open(rel)
703+ if err != nil {
704+ return nil, err
705+ }
706+ defer func() { _ = d.Close() }()
707+ ents, err := d.ReadDir(-1)
708+ if err != nil {
709+ return nil, err
710+ }
711+ names := make([]string, 0, len(ents))
712+ for _, e := range ents {
713+ names = append(names, e.Name())
714+ }
715+ sort.Strings(names)
716+ return names, nil
717+}
718+
719+// hashOne is the SHA-256 digest of a regular file. ok is false for what is not
720+// hashed: a directory (reached through a link), a special file, a link that
721+// leaves the workspace or is broken.
722+func hashOne(ctx context.Context, root *os.Root, rel string) (sum []byte, ok bool, err error) {
723+ f, err := root.Open(rel)
724+ if err != nil {
725+ return nil, false, nil
726+ }
727+ defer func() { _ = f.Close() }()
728+ st, err := f.Stat()
729+ if err != nil || !st.Mode().IsRegular() {
730+ return nil, false, nil
731+ }
732+ h := sha256.New()
733+ if _, err := io.Copy(h, &ctxReader{ctx, f}); err != nil {
734+ return nil, false, err
735+ }
736+ return h.Sum(nil), true, nil
737+}
738+
739+type ctxReader struct {
740+ ctx context.Context
741+ r io.Reader
742+}
743+
744+func (c *ctxReader) Read(p []byte) (int, error) {
745+ if err := c.ctx.Err(); err != nil {
746+ return 0, err
747+ }
748+ return c.r.Read(p)
749+}
@@ -0,0 +1,749 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "encoding/hex"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "io/fs"
13+ "os"
14+ "path"
15+ "regexp"
16+ "sort"
17+ "strings"
18+ "time"
19+
20+ "bnema.dev/ermite-actions/expr"
21+)
22+
23+// hashFiles is the function of the same name (Runner.Worker/Expressions/
24+// HashFilesFunction.cs). The official runner starts node on its hashFiles
25+// script (Misc/expressionFunc/hashFiles, which uses @actions/glob); this is a
26+// port of that script, evaluated by the executor, which runs in the sandbox
27+// next to the workspace.
28+//
29+// For each file that matches the patterns, in the order of @actions/glob's
30+// traversal (a depth first walk with the names of a directory sorted), the
31+// SHA-256 of the file is written to a second SHA-256; the result is its
32+// hexadecimal digest, or "" when no file matched.
33+//
34+// Differences from the official function, on purpose: the files are read
35+// through an os.Root on the workspace, so that a symbolic link that leaves the
36+// workspace is not followed (the executor can be root, the workspace belongs
37+// to the user of the steps) and its file is skipped; and only regular files
38+// are hashed (a FIFO would block until the timeout). A matched symbolic link
39+// whose target is missing fails the call, as it does in the script. Braces ({a,b}), extglobs (+(a|b)) and a leading
40+// "!" in the middle of a pattern are not special, as in @actions/glob.
41+const hashFilesTimeout = 120 * time.Second
42+
43+func (j *job) hashFilesFunc() expr.Func {
44+ return func(ctx context.Context, args []expr.Value) (expr.Value, error) {
45+ var patterns []string
46+ follow := false
47+ for i, a := range args {
48+ p := expr.CoerceString(a)
49+ if i == 0 && strings.HasPrefix(p, "--") {
50+ if strings.EqualFold(p, "--follow-symbolic-links") {
51+ follow = true
52+ continue
53+ }
54+ // new ArgumentOutOfRangeException(message) takes the text as the parameter name.
55+ //nolint:staticcheck,misspell // GitHub's text, typo included
56+ return nil, fmt.Errorf("Specified argument was out of the range of valid values. (Parameter 'Invalid glob option %s, avaliable option: '--follow-symbolic-links'.')", p)
57+ }
58+ patterns = append(patterns, p)
59+ }
60+ ws := j.state.Layout().Workspace
61+ ctx, cancel := context.WithTimeout(ctx, hashFilesTimeout)
62+ defer cancel()
63+ sum, err := hashFilesIn(ctx, ws, j.state.Layout().Home, patterns, follow)
64+ desc := strings.ReplaceAll(strings.Join(patterns, ", "), "'", "''")
65+ switch {
66+ case errors.Is(err, context.DeadlineExceeded):
67+ //nolint:staticcheck // GitHub's text, with its final period
68+ return nil, fmt.Errorf("hashFiles('%s') couldn't finish within %d seconds.", desc, int(hashFilesTimeout/time.Second))
69+ case err != nil:
70+ j.cfg.Log.Debug().Err(err).Msg("hashFiles failed")
71+ return nil, fmt.Errorf("hashFiles('%s') failed. Fail to hash files under directory '%s'", desc, ws)
72+ }
73+ return expr.String(sum), nil
74+ }
75+}
76+
77+// ---------------------------------------------------------------- patterns
78+
79+// seg is a segment of a pattern: its literal value (getLiteral, "" when it has
80+// a wildcard) and its minimatch form.
81+type seg struct {
82+ raw string // the segment as written, for the "**" test
83+ lit string
84+ mm mmSeg
85+ root bool
86+}
87+
88+// mmSeg is a parsed segment: the globstar, a literal, or a regular expression.
89+type mmSeg struct {
90+ globstar bool
91+ lit string
92+ re *regexp.Regexp
93+}
94+
95+type globPattern struct {
96+ negate bool
97+ segs []seg
98+ trailingSep bool
99+ implicit bool
100+ searchPath string
101+}
102+
103+const (
104+ kindNone = 0
105+ kindDir = 1
106+ kindFile = 2
107+ kindAll = 3
108+)
109+
110+func splitSlash(s string) []string {
111+ return regexp.MustCompile(`/+`).Split(s, -1)
112+}
113+
114+var errPattern = errors.New("invalid pattern")
115+
116+// newGlobPattern is Pattern's constructor for a pattern written by the user,
117+// relative to cwd (the workspace, as segments).
118+func newGlobPattern(text, cwd, home string) (*globPattern, error) {
119+ p := &globPattern{}
120+ text = strings.TrimSpace(text)
121+ for strings.HasPrefix(text, "!") {
122+ p.negate = !p.negate
123+ text = strings.TrimSpace(text[1:])
124+ }
125+ if text == "" {
126+ return nil, fmt.Errorf("%w: pattern cannot be empty", errPattern)
127+ }
128+ norm := regexp.MustCompile(`//+`).ReplaceAllString(text, "/")
129+ p.trailingSep = strings.HasSuffix(norm, "/")
130+ trimmed := norm
131+ if trimmed != "/" {
132+ trimmed = strings.TrimSuffix(trimmed, "/")
133+ }
134+ rooted := strings.HasPrefix(trimmed, "/")
135+ var rawSegs []string
136+ if rooted {
137+ rawSegs = append([]string{"/"}, splitNonEmpty(trimmed)...)
138+ } else {
139+ rawSegs = strings.Split(trimmed, "/")
140+ }
141+ for i, s := range rawSegs {
142+ l := getLiteral(s)
143+ if (l == "." && i != 0) || l == ".." {
144+ return nil, fmt.Errorf("%w: relative pathing '.' and '..' is not allowed: %s", errPattern, text)
145+ }
146+ }
147+ var prefix []seg
148+ rest := rawSegs
149+ switch {
150+ case trimmed == "." || strings.HasPrefix(trimmed, "./"):
151+ prefix, rest = literalSegs(cwd), rawSegs[1:]
152+ case trimmed == "~" || strings.HasPrefix(trimmed, "~/"):
153+ prefix, rest = literalSegs(home), rawSegs[1:]
154+ case rooted:
155+ rest = rawSegs[1:]
156+ prefix = []seg{{raw: "/", lit: "/", mm: mmSeg{lit: ""}, root: true}}
157+ default:
158+ prefix = literalSegs(cwd)
159+ }
160+ p.segs = prefix
161+ for _, r := range rest {
162+ p.segs = append(p.segs, seg{raw: r, lit: getLiteral(r), mm: parseMM(r)})
163+ }
164+ p.computeSearchPath()
165+ return p, nil
166+}
167+
168+func splitNonEmpty(s string) []string {
169+ var out []string
170+ for _, x := range strings.Split(s, "/") {
171+ if x != "" {
172+ out = append(out, x)
173+ }
174+ }
175+ return out
176+}
177+
178+// literalSegs is the absolute path abs as literal segments.
179+func literalSegs(abs string) []seg {
180+ out := []seg{{raw: "/", lit: "/", mm: mmSeg{lit: ""}, root: true}}
181+ for _, s := range splitNonEmpty(abs) {
182+ out = append(out, seg{raw: "", lit: s, mm: mmSeg{lit: s}})
183+ }
184+ return out
185+}
186+
187+func (p *globPattern) computeSearchPath() {
188+ var parts []string
189+ for _, s := range p.segs {
190+ if s.lit == "" {
191+ break
192+ }
193+ parts = append(parts, s.lit)
194+ }
195+ p.searchPath = joinSegs(parts)
196+}
197+
198+func joinSegs(parts []string) string {
199+ if len(parts) == 0 {
200+ return ""
201+ }
202+ out := parts[0]
203+ for _, s := range parts[1:] {
204+ if !strings.HasSuffix(out, "/") {
205+ out += "/"
206+ }
207+ out += s
208+ }
209+ return out
210+}
211+
212+func (p *globPattern) withDescendants() *globPattern {
213+ q := *p
214+ q.segs = append(append([]seg(nil), p.segs...), seg{raw: "**", lit: "", mm: mmSeg{globstar: true}})
215+ q.trailingSep = false
216+ q.implicit = true
217+ q.computeSearchPath()
218+ return &q
219+}
220+
221+// getLiteral is Pattern.getLiteral: the segment with its escapes removed, or ""
222+// when it has a wildcard.
223+func getLiteral(s string) string {
224+ r := []rune(s)
225+ var lit []rune
226+ for i := 0; i < len(r); i++ {
227+ c := r[i]
228+ switch {
229+ case c == '\\' && i+1 < len(r):
230+ i++
231+ lit = append(lit, r[i])
232+ case c == '*' || c == '?':
233+ return ""
234+ case c == '[' && i+1 < len(r):
235+ var set []rune
236+ closed := -1
237+ for i2 := i + 1; i2 < len(r); i2++ {
238+ c2 := r[i2]
239+ if c2 == '\\' && i2+1 < len(r) {
240+ i2++
241+ set = append(set, r[i2])
242+ continue
243+ }
244+ if c2 == ']' {
245+ closed = i2
246+ break
247+ }
248+ set = append(set, c2)
249+ }
250+ if closed >= 0 {
251+ if len(set) > 1 {
252+ return ""
253+ }
254+ if len(set) == 1 {
255+ lit = append(lit, set...)
256+ i = closed
257+ continue
258+ }
259+ }
260+ lit = append(lit, c)
261+ default:
262+ lit = append(lit, c)
263+ }
264+ }
265+ return string(lit)
266+}
267+
268+const reSpecials = "().*{}+?[]^$\\!"
269+
270+// parseMM is minimatch's parse for one segment with the options of
271+// @actions/glob (dot, nobrace, noext, nonegate, nocomment).
272+func parseMM(s string) mmSeg {
273+ if s == "**" {
274+ return mmSeg{globstar: true}
275+ }
276+ if s == "" {
277+ return mmSeg{lit: ""}
278+ }
279+ re, magic := translateMM([]rune(s))
280+ if !magic {
281+ return mmSeg{lit: globUnescape(s)}
282+ }
283+ rx, err := regexp.Compile("^" + re + "$")
284+ if err != nil {
285+ return mmSeg{re: regexp.MustCompile(`$.`)}
286+ }
287+ return mmSeg{re: rx}
288+}
289+
290+func globUnescape(s string) string {
291+ r := []rune(s)
292+ var out []rune
293+ for i := 0; i < len(r); i++ {
294+ if r[i] == '\\' && i+1 < len(r) {
295+ i++
296+ }
297+ out = append(out, r[i])
298+ }
299+ return string(out)
300+}
301+
302+func translateMM(p []rune) (re string, magic bool) {
303+ var sb strings.Builder
304+ escaping, inClass := false, false
305+ classStart, reClassStart := -1, -1
306+ for i := 0; i < len(p); i++ {
307+ c := p[i]
308+ if escaping && strings.ContainsRune(reSpecials, c) {
309+ sb.WriteString(`\` + string(c))
310+ escaping = false
311+ continue
312+ }
313+ switch c {
314+ case '\\':
315+ escaping = true
316+ case '?', '*', '+', '@', '!':
317+ if inClass {
318+ if c == '!' && i == classStart+1 {
319+ c = '^'
320+ }
321+ sb.WriteRune(c)
322+ continue
323+ }
324+ switch c {
325+ case '*':
326+ sb.WriteString(`[^/]*?`)
327+ magic = true
328+ case '?':
329+ sb.WriteString(`[^/]`)
330+ magic = true
331+ default:
332+ sb.WriteString(`\` + string(c))
333+ }
334+ case '(':
335+ if inClass {
336+ sb.WriteRune('(')
337+ } else {
338+ sb.WriteString(`\(`)
339+ }
340+ case ')':
341+ sb.WriteString(`\)`)
342+ case '|':
343+ sb.WriteString(`\|`)
344+ escaping = false
345+ case '[':
346+ if inClass {
347+ sb.WriteString(`\[`)
348+ continue
349+ }
350+ inClass, classStart, reClassStart = true, i, sb.Len()
351+ sb.WriteRune('[')
352+ case ']':
353+ if i == classStart+1 || !inClass {
354+ sb.WriteString(`\]`)
355+ escaping = false
356+ continue
357+ }
358+ cs := string(p[classStart+1 : i])
359+ if _, err := regexp.Compile("[" + cs + "]"); err != nil {
360+ sub, m := translateMM([]rune(cs))
361+ cur := sb.String()
362+ sb.Reset()
363+ sb.WriteString(cur[:reClassStart] + `\[` + sub + `\]`)
364+ magic = magic || m
365+ inClass = false
366+ continue
367+ }
368+ magic = true
369+ inClass = false
370+ sb.WriteRune(']')
371+ default:
372+ if escaping {
373+ escaping = false
374+ } else if strings.ContainsRune(reSpecials, c) && (c != '^' || !inClass) {
375+ sb.WriteRune('\\')
376+ }
377+ sb.WriteRune(c)
378+ }
379+ }
380+ if inClass {
381+ cs := string(p[classStart+1:])
382+ sub, m := translateMM([]rune(cs))
383+ cur := sb.String()
384+ sb.Reset()
385+ sb.WriteString(cur[:reClassStart] + `\[` + sub)
386+ magic = magic || m
387+ }
388+ if escaping {
389+ sb.WriteString(`\\`)
390+ }
391+ return sb.String(), magic
392+}
393+
394+// match is Pattern.match.
395+func (p *globPattern) match(item string) int {
396+ if p.segs[len(p.segs)-1].raw == "**" {
397+ item = regexp.MustCompile(`//+`).ReplaceAllString(item, "/")
398+ if !strings.HasSuffix(item, "/") && !p.implicit {
399+ item += "/"
400+ }
401+ } else {
402+ item = trimTrailingSep(item)
403+ }
404+ if p.matchOne(splitSlash(item), p.mmSegs(), 0, 0, false) {
405+ if p.trailingSep {
406+ return kindDir
407+ }
408+ return kindAll
409+ }
410+ return kindNone
411+}
412+
413+func (p *globPattern) mmSegs() []mmSeg {
414+ out := make([]mmSeg, len(p.segs))
415+ for i, s := range p.segs {
416+ out[i] = s.mm
417+ }
418+ return out
419+}
420+
421+// partialMatch is Pattern.partialMatch.
422+func (p *globPattern) partialMatch(item string) bool {
423+ item = trimTrailingSep(item)
424+ if path.Dir(item) == item {
425+ return true // the root matches the root of every pattern
426+ }
427+ return p.matchOne(splitSlash(item), p.mmSegs(), 0, 0, true)
428+}
429+
430+func trimTrailingSep(s string) string {
431+ s = regexp.MustCompile(`//+`).ReplaceAllString(s, "/")
432+ if s != "/" && strings.HasSuffix(s, "/") {
433+ return s[:len(s)-1]
434+ }
435+ return s
436+}
437+
438+func isDotName(s string) bool { return s == "." || s == ".." }
439+
440+// matchOne is Minimatch.matchOne with the dot option set.
441+func (p *globPattern) matchOne(file []string, pat []mmSeg, fi, pi int, partial bool) bool {
442+ fl, pl := len(file), len(pat)
443+ for ; fi < fl && pi < pl; fi, pi = fi+1, pi+1 {
444+ ps, f := pat[pi], file[fi]
445+ if ps.globstar {
446+ fr, pr := fi, pi+1
447+ if pr == pl {
448+ for ; fi < fl; fi++ {
449+ if isDotName(file[fi]) {
450+ return false
451+ }
452+ }
453+ return true
454+ }
455+ for fr < fl {
456+ if p.matchOne(file, pat, fr, pr, partial) {
457+ return true
458+ }
459+ if isDotName(file[fr]) {
460+ break
461+ }
462+ fr++
463+ }
464+ if partial && fr == fl {
465+ return true
466+ }
467+ return false
468+ }
469+ var hit bool
470+ if ps.re != nil {
471+ hit = ps.re.MatchString(f)
472+ } else {
473+ hit = f == ps.lit
474+ }
475+ if !hit {
476+ return false
477+ }
478+ }
479+ switch {
480+ case fi == fl && pi == pl:
481+ return true
482+ case fi == fl:
483+ return partial
484+ default:
485+ return fi == fl-1 && file[fi] == ""
486+ }
487+}
488+
489+// ---------------------------------------------------------------- walk
490+
491+func matchAll(patterns []*globPattern, item string) int {
492+ r := kindNone
493+ for _, p := range patterns {
494+ if p.negate {
495+ r &^= p.match(item)
496+ } else {
497+ r |= p.match(item)
498+ }
499+ }
500+ return r
501+}
502+
503+// getSearchPaths is internal-pattern-helper's getSearchPaths.
504+func getSearchPaths(patterns []*globPattern) []string {
505+ var pos []*globPattern
506+ for _, p := range patterns {
507+ if !p.negate {
508+ pos = append(pos, p)
509+ }
510+ }
511+ state := map[string]string{}
512+ for _, p := range pos {
513+ state[p.searchPath] = "candidate"
514+ }
515+ var out []string
516+ for _, p := range pos {
517+ if state[p.searchPath] == "included" {
518+ continue
519+ }
520+ found := false
521+ key := p.searchPath
522+ for parent := path.Dir(key); parent != key; parent = path.Dir(key) {
523+ if state[parent] != "" {
524+ found = true
525+ break
526+ }
527+ key = parent
528+ }
529+ if !found {
530+ out = append(out, p.searchPath)
531+ state[p.searchPath] = "included"
532+ }
533+ }
534+ return out
535+}
536+
537+type searchItem struct {
538+ path string
539+ level int
540+}
541+
542+// hashFilesIn is the hashFiles script: the hex SHA-256 of the SHA-256 digests
543+// of the files under ws that match the patterns, "" when none does.
544+func hashFilesIn(ctx context.Context, ws, home string, lines []string, follow bool) (string, error) {
545+ cwd := path.Clean(ws)
546+ var patterns []*globPattern
547+ for _, line := range strings.Split(strings.Join(lines, "\n"), "\n") {
548+ line = strings.TrimSpace(line)
549+ if line == "" || strings.HasPrefix(line, "#") {
550+ continue
551+ }
552+ p, err := newGlobPattern(line, cwd, home)
553+ if err != nil {
554+ return "", err
555+ }
556+ patterns = append(patterns, p)
557+ }
558+ all := make([]*globPattern, 0, 2*len(patterns))
559+ for _, p := range patterns {
560+ all = append(all, p)
561+ if p.trailingSep || p.segs[len(p.segs)-1].raw != "**" {
562+ all = append(all, p.withDescendants())
563+ }
564+ }
565+ root, err := os.OpenRoot(cwd)
566+ if err != nil {
567+ if errors.Is(err, fs.ErrNotExist) {
568+ return "", nil
569+ }
570+ return "", err
571+ }
572+ defer func() { _ = root.Close() }()
573+ rel := func(abs string) (string, bool) {
574+ if abs == cwd {
575+ return ".", true
576+ }
577+ if strings.HasPrefix(abs, cwd+"/") {
578+ return abs[len(cwd)+1:], true
579+ }
580+ return "", false
581+ }
582+
583+ var stack []searchItem
584+ for _, sp := range getSearchPaths(all) {
585+ if _, ok := rel(sp); !ok {
586+ if !strings.HasPrefix(cwd+"/", strings.TrimSuffix(sp, "/")+"/") {
587+ continue // neither under the workspace nor above it
588+ }
589+ sp = cwd
590+ }
591+ r, _ := rel(sp)
592+ if _, err := root.Lstat(r); err != nil {
593+ if errors.Is(err, fs.ErrNotExist) {
594+ continue
595+ }
596+ return "", err
597+ }
598+ stack = append([]searchItem{{sp, 1}}, stack...)
599+ }
600+
601+ total := sha256.New()
602+ hashed := 0
603+ var chain []fs.FileInfo
604+ for len(stack) > 0 {
605+ if err := ctx.Err(); err != nil {
606+ return "", err
607+ }
608+ item := stack[len(stack)-1]
609+ stack = stack[:len(stack)-1]
610+ match := matchAll(all, item.path)
611+ partial := match != kindNone
612+ if !partial {
613+ for _, p := range all {
614+ if !p.negate && p.partialMatch(item.path) {
615+ partial = true
616+ break
617+ }
618+ }
619+ }
620+ if !partial {
621+ continue
622+ }
623+ r, under := rel(item.path)
624+ if !under {
625+ // Above the workspace (an absolute pattern): only the way down matters.
626+ r = "."
627+ if item.path != "/" && !strings.HasPrefix(cwd+"/", item.path+"/") {
628+ continue
629+ }
630+ }
631+ var st fs.FileInfo
632+ if follow {
633+ st, err = root.Stat(r)
634+ } else {
635+ st, err = root.Lstat(r)
636+ }
637+ if err != nil {
638+ if errors.Is(err, fs.ErrNotExist) || follow {
639+ continue // vanished, or a broken or escaping link
640+ }
641+ return "", err
642+ }
643+ if st.IsDir() && follow {
644+ for len(chain) >= item.level {
645+ chain = chain[:len(chain)-1]
646+ }
647+ cycle := false
648+ for _, c := range chain {
649+ if os.SameFile(c, st) {
650+ cycle = true
651+ }
652+ }
653+ if cycle {
654+ continue
655+ }
656+ chain = append(chain, st)
657+ }
658+ if st.IsDir() {
659+ if match&kindDir == 0 && !partial {
660+ continue
661+ }
662+ names, err := readDirNames(root, r)
663+ if err != nil {
664+ return "", err
665+ }
666+ base := item.path
667+ for i := len(names) - 1; i >= 0; i-- {
668+ stack = append(stack, searchItem{path.Join(base, names[i]), item.level + 1})
669+ }
670+ continue // directories are skipped by hashFiles
671+ }
672+ if match&kindFile == 0 || !under {
673+ continue
674+ }
675+ if !st.Mode().IsRegular() && !follow && st.Mode()&fs.ModeSymlink == 0 {
676+ continue
677+ }
678+ sum, ok, err := hashOne(ctx, root, r)
679+ if err != nil {
680+ return "", err
681+ }
682+ if !ok {
683+ if st.Mode()&fs.ModeSymlink != 0 {
684+ if _, serr := root.Stat(r); errors.Is(serr, fs.ErrNotExist) {
685+ // The script stats the link's target and fails when it is
686+ // missing (fs.statSync throws ENOENT): so does the function.
687+ return "", fmt.Errorf("broken symbolic link %s: %w", item.path, serr)
688+ }
689+ }
690+ continue
691+ }
692+ total.Write(sum)
693+ hashed++
694+ }
695+ if hashed == 0 {
696+ return "", nil
697+ }
698+ return hex.EncodeToString(total.Sum(nil)), nil
699+}
700+
701+func readDirNames(root *os.Root, rel string) ([]string, error) {
702+ d, err := root.Open(rel)
703+ if err != nil {
704+ return nil, err
705+ }
706+ defer func() { _ = d.Close() }()
707+ ents, err := d.ReadDir(-1)
708+ if err != nil {
709+ return nil, err
710+ }
711+ names := make([]string, 0, len(ents))
712+ for _, e := range ents {
713+ names = append(names, e.Name())
714+ }
715+ sort.Strings(names)
716+ return names, nil
717+}
718+
719+// hashOne is the SHA-256 digest of a regular file. ok is false for what is not
720+// hashed: a directory (reached through a link), a special file, a link that
721+// leaves the workspace or is broken.
722+func hashOne(ctx context.Context, root *os.Root, rel string) (sum []byte, ok bool, err error) {
723+ f, err := root.Open(rel)
724+ if err != nil {
725+ return nil, false, nil
726+ }
727+ defer func() { _ = f.Close() }()
728+ st, err := f.Stat()
729+ if err != nil || !st.Mode().IsRegular() {
730+ return nil, false, nil
731+ }
732+ h := sha256.New()
733+ if _, err := io.Copy(h, &ctxReader{ctx, f}); err != nil {
734+ return nil, false, err
735+ }
736+ return h.Sum(nil), true, nil
737+}
738+
739+type ctxReader struct {
740+ ctx context.Context
741+ r io.Reader
742+}
743+
744+func (c *ctxReader) Read(p []byte) (int, error) {
745+ if err := c.ctx.Err(); err != nil {
746+ return 0, err
747+ }
748+ return c.r.Read(p)
749+}
A internal/executor/hashfiles_test.go +167 -0
@@ -0,0 +1,167 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+)
12+
13+func writeTree(t *testing.T, root string, files map[string]string) {
14+ t.Helper()
15+ for name, body := range files {
16+ p := filepath.Join(root, filepath.FromSlash(name))
17+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
21+ t.Fatal(err)
22+ }
23+ }
24+}
25+
26+// Values recorded on github.com (expr-hashfiles), for the files the scenario
27+// creates: alpha, beta and gamma.
28+func TestHashFilesRecordedValues(t *testing.T) {
29+ ws := t.TempDir()
30+ writeTree(t, ws, map[string]string{"hf/a.txt": "alpha\n", "hf/b.txt": "beta\n", "hf/sub/c.md": "gamma\n"})
31+ for _, tc := range []struct {
32+ patterns []string
33+ want string
34+ }{
35+ {[]string{"hf/a.txt"}, "4bb706b95c7ea23f44bc5d035ad8841af479871295d2ae0c685d07174705c880"},
36+ {[]string{"hf/a.txt", "hf/b.txt"}, "24d116e0411b3a4a8d3d5c9c88c150bc4d4603a490294bd4b23d3ef549e1f1a0"},
37+ {[]string{"hf/b.txt", "hf/a.txt"}, "a3013a35661fd0340c6651bfd9b89fd7c86e916011c7e9cfecfb835d90ec7505"},
38+ {[]string{"hf/*.txt"}, "24d116e0411b3a4a8d3d5c9c88c150bc4d4603a490294bd4b23d3ef549e1f1a0"},
39+ {[]string{"hf/**"}, "58d680606dbb1f9fab754d2d071e528858b6ca169b64be479ae9557d72c18d77"},
40+ {[]string{"hf/**/*.md"}, "b9f1217dc10c76d1ab32754b4df630a29025a4a878aeddd113fda7045f9faf46"},
41+ {[]string{"hf/**", "!hf/sub/**"}, "24d116e0411b3a4a8d3d5c9c88c150bc4d4603a490294bd4b23d3ef549e1f1a0"},
42+ {[]string{"hf/nothing-*"}, ""},
43+ } {
44+ got, err := hashFilesIn(t.Context(), ws, "/home/x", tc.patterns, false)
45+ if err != nil || got != tc.want {
46+ t.Errorf("%q = %q (%v), want %q", tc.patterns, got, err, tc.want)
47+ }
48+ }
49+ // "before files exist": the directory is not there yet.
50+ got, err := hashFilesIn(t.Context(), t.TempDir(), "/home/x", []string{"hf/**"}, false)
51+ if err != nil || got != "" {
52+ t.Errorf("empty workspace: %q %v", got, err)
53+ }
54+}
55+
56+// The scenario end to end, in expressions, against the recording.
57+func TestCorpusHashFiles(t *testing.T) {
58+ compareWithRecording(t, "expr-hashfiles")
59+}
60+
61+// Differential test: the official script (Misc/expressionFunc/hashFiles, run by
62+// node) and this port must agree. Opt in with ERMITE_HASHFILES_JS=<path of the
63+// script, Misc/layoutbin/hashFiles/index.js of actions/runner>.
64+func TestHashFilesMatchesOfficialScript(t *testing.T) {
65+ script := os.Getenv("ERMITE_HASHFILES_JS")
66+ if script == "" {
67+ t.Skip("set ERMITE_HASHFILES_JS to Misc/layoutbin/hashFiles/index.js of actions/runner")
68+ }
69+ needTools(t, "node")
70+ ws := t.TempDir()
71+ writeTree(t, ws, map[string]string{
72+ "a.txt": "a", "b.go": "b", ".hidden/x": "h", ".dotfile": "d", "src/main.go": "m", "src/util/u.go": "u",
73+ "src/util/u_test.go": "t", "src/Zeta.go": "z", "src/alpha.GO": "A", "docs/readme.md": "r", "docs/img/p.png": "p",
74+ "node_modules/pkg/index.js": "n", "weird [name]/f.txt": "w", "a b/c d.txt": "s", "go.mod": "g", "go.sum": "s2",
75+ "deep/1/2/3/4/f": "deep", "ünï/çode.txt": "u",
76+ })
77+ if err := os.Symlink("src", filepath.Join(ws, "link")); err != nil {
78+ t.Fatal(err)
79+ }
80+ if err := os.Symlink("missing", filepath.Join(ws, "broken")); err != nil {
81+ t.Fatal(err)
82+ }
83+ for _, patterns := range [][]string{
84+ {"**"}, {"*"}, {"**/*.go"}, {"**/*.go", "!**/*_test.go"}, {"src/**", "!src/util/**"}, {"**/go.sum", "**/go.mod"},
85+ {"go.mod", "go.sum"}, {"src/*.go"}, {"src/?lpha.GO"}, {"src/[Zz]eta.go"}, {"**/*.{go,md}"}, {"docs"}, {"docs/"}, {"docs/*"},
86+ {"./src/main.go"}, {"**/.hidden/**"}, {".*"}, {"**/node_modules/**", "src/**"}, {"!src/**", "**/*.go"}, {"nothing"}, {"**/*.md", "# a comment", ""},
87+ {"weird [[]name]/f.txt"}, {"weird*/*"}, {"a b/*"}, {"src/**/", "docs/**"}, {"ünï/*"}, {"deep/**/f"}, {"**/f"}, {"link/**"}, {"link/main.go"},
88+ {"broken"}, {"**/*.GO"}, {"src/util/../main.go"}, {"/etc/hostname"}, {"src\\main.go"}, {"**/u*"}, {"*/*/*"}, {"src/**/*", "!src/util"},
89+ } {
90+ for _, follow := range []bool{false, true} {
91+ want := officialHash(t, script, ws, patterns, follow)
92+ got, err := hashFilesIn(t.Context(), ws, "/home/x", patterns, follow)
93+ if want.failed {
94+ if err == nil {
95+ t.Errorf("%q follow=%v: the script fails (%s), got %q", patterns, follow, want.msg, got)
96+ }
97+ continue
98+ }
99+ if err != nil || got != want.sum {
100+ t.Errorf("%q follow=%v: got %q (%v), script %q", patterns, follow, got, err, want.sum)
101+ }
102+ }
103+ }
104+}
105+
106+// A symbolic link that leaves the workspace is not followed, whatever the
107+// script does: the executor may run as root and the workspace is the user's.
108+func TestHashFilesDoesNotLeaveTheWorkspace(t *testing.T) {
109+ outside := t.TempDir()
110+ writeTree(t, outside, map[string]string{"secret": "s"})
111+ ws := t.TempDir()
112+ writeTree(t, ws, map[string]string{"a.txt": "alpha\n"})
113+ if err := os.Symlink(filepath.Join(outside, "secret"), filepath.Join(ws, "leak")); err != nil {
114+ t.Fatal(err)
115+ }
116+ if err := os.Symlink(outside, filepath.Join(ws, "leakdir")); err != nil {
117+ t.Fatal(err)
118+ }
119+ only, _ := hashFilesIn(t.Context(), ws, "/h", []string{"a.txt"}, false)
120+ for _, follow := range []bool{false, true} {
121+ got, err := hashFilesIn(t.Context(), ws, "/h", []string{"**"}, follow)
122+ if err != nil || got != only {
123+ t.Errorf("follow=%v: %q (%v), want the hash of a.txt alone %q", follow, got, err, only)
124+ }
125+ }
126+}
127+
128+// A matched symbolic link without a target fails the call, like the script.
129+func TestHashFilesBrokenLinkFails(t *testing.T) {
130+ ws := t.TempDir()
131+ if err := os.Symlink("missing", filepath.Join(ws, "broken")); err != nil {
132+ t.Fatal(err)
133+ }
134+ if _, err := hashFilesIn(t.Context(), ws, "/h", []string{"**"}, false); err == nil {
135+ t.Error("want an error")
136+ }
137+ if got, err := hashFilesIn(t.Context(), ws, "/h", []string{"**"}, true); err != nil || got != "" {
138+ t.Errorf("followed, the link is omitted: %q %v", got, err)
139+ }
140+}
141+
142+type officialResult struct {
143+ sum string
144+ failed bool
145+ msg string
146+}
147+
148+func officialHash(t *testing.T, script, ws string, patterns []string, follow bool) officialResult {
149+ t.Helper()
150+ cmd := exec.Command("node", script)
151+ cmd.Dir = ws
152+ cmd.Env = append(os.Environ(), "patterns="+strings.Join(patterns, "\n"))
153+ if follow {
154+ cmd.Env = append(cmd.Env, "followSymbolicLinks=true")
155+ }
156+ out, err := cmd.CombinedOutput()
157+ if err != nil {
158+ return officialResult{failed: true, msg: string(out)}
159+ }
160+ s := string(out)
161+ i := strings.Index(s, "__OUTPUT__")
162+ j := strings.LastIndex(s, "__OUTPUT__")
163+ if i < 0 || j <= i {
164+ t.Fatalf("no output: %s", s)
165+ }
166+ return officialResult{sum: s[i+10 : j]}
167+}
@@ -0,0 +1,167 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+)
12+
13+func writeTree(t *testing.T, root string, files map[string]string) {
14+ t.Helper()
15+ for name, body := range files {
16+ p := filepath.Join(root, filepath.FromSlash(name))
17+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
21+ t.Fatal(err)
22+ }
23+ }
24+}
25+
26+// Values recorded on github.com (expr-hashfiles), for the files the scenario
27+// creates: alpha, beta and gamma.
28+func TestHashFilesRecordedValues(t *testing.T) {
29+ ws := t.TempDir()
30+ writeTree(t, ws, map[string]string{"hf/a.txt": "alpha\n", "hf/b.txt": "beta\n", "hf/sub/c.md": "gamma\n"})
31+ for _, tc := range []struct {
32+ patterns []string
33+ want string
34+ }{
35+ {[]string{"hf/a.txt"}, "4bb706b95c7ea23f44bc5d035ad8841af479871295d2ae0c685d07174705c880"},
36+ {[]string{"hf/a.txt", "hf/b.txt"}, "24d116e0411b3a4a8d3d5c9c88c150bc4d4603a490294bd4b23d3ef549e1f1a0"},
37+ {[]string{"hf/b.txt", "hf/a.txt"}, "a3013a35661fd0340c6651bfd9b89fd7c86e916011c7e9cfecfb835d90ec7505"},
38+ {[]string{"hf/*.txt"}, "24d116e0411b3a4a8d3d5c9c88c150bc4d4603a490294bd4b23d3ef549e1f1a0"},
39+ {[]string{"hf/**"}, "58d680606dbb1f9fab754d2d071e528858b6ca169b64be479ae9557d72c18d77"},
40+ {[]string{"hf/**/*.md"}, "b9f1217dc10c76d1ab32754b4df630a29025a4a878aeddd113fda7045f9faf46"},
41+ {[]string{"hf/**", "!hf/sub/**"}, "24d116e0411b3a4a8d3d5c9c88c150bc4d4603a490294bd4b23d3ef549e1f1a0"},
42+ {[]string{"hf/nothing-*"}, ""},
43+ } {
44+ got, err := hashFilesIn(t.Context(), ws, "/home/x", tc.patterns, false)
45+ if err != nil || got != tc.want {
46+ t.Errorf("%q = %q (%v), want %q", tc.patterns, got, err, tc.want)
47+ }
48+ }
49+ // "before files exist": the directory is not there yet.
50+ got, err := hashFilesIn(t.Context(), t.TempDir(), "/home/x", []string{"hf/**"}, false)
51+ if err != nil || got != "" {
52+ t.Errorf("empty workspace: %q %v", got, err)
53+ }
54+}
55+
56+// The scenario end to end, in expressions, against the recording.
57+func TestCorpusHashFiles(t *testing.T) {
58+ compareWithRecording(t, "expr-hashfiles")
59+}
60+
61+// Differential test: the official script (Misc/expressionFunc/hashFiles, run by
62+// node) and this port must agree. Opt in with ERMITE_HASHFILES_JS=<path of the
63+// script, Misc/layoutbin/hashFiles/index.js of actions/runner>.
64+func TestHashFilesMatchesOfficialScript(t *testing.T) {
65+ script := os.Getenv("ERMITE_HASHFILES_JS")
66+ if script == "" {
67+ t.Skip("set ERMITE_HASHFILES_JS to Misc/layoutbin/hashFiles/index.js of actions/runner")
68+ }
69+ needTools(t, "node")
70+ ws := t.TempDir()
71+ writeTree(t, ws, map[string]string{
72+ "a.txt": "a", "b.go": "b", ".hidden/x": "h", ".dotfile": "d", "src/main.go": "m", "src/util/u.go": "u",
73+ "src/util/u_test.go": "t", "src/Zeta.go": "z", "src/alpha.GO": "A", "docs/readme.md": "r", "docs/img/p.png": "p",
74+ "node_modules/pkg/index.js": "n", "weird [name]/f.txt": "w", "a b/c d.txt": "s", "go.mod": "g", "go.sum": "s2",
75+ "deep/1/2/3/4/f": "deep", "ünï/çode.txt": "u",
76+ })
77+ if err := os.Symlink("src", filepath.Join(ws, "link")); err != nil {
78+ t.Fatal(err)
79+ }
80+ if err := os.Symlink("missing", filepath.Join(ws, "broken")); err != nil {
81+ t.Fatal(err)
82+ }
83+ for _, patterns := range [][]string{
84+ {"**"}, {"*"}, {"**/*.go"}, {"**/*.go", "!**/*_test.go"}, {"src/**", "!src/util/**"}, {"**/go.sum", "**/go.mod"},
85+ {"go.mod", "go.sum"}, {"src/*.go"}, {"src/?lpha.GO"}, {"src/[Zz]eta.go"}, {"**/*.{go,md}"}, {"docs"}, {"docs/"}, {"docs/*"},
86+ {"./src/main.go"}, {"**/.hidden/**"}, {".*"}, {"**/node_modules/**", "src/**"}, {"!src/**", "**/*.go"}, {"nothing"}, {"**/*.md", "# a comment", ""},
87+ {"weird [[]name]/f.txt"}, {"weird*/*"}, {"a b/*"}, {"src/**/", "docs/**"}, {"ünï/*"}, {"deep/**/f"}, {"**/f"}, {"link/**"}, {"link/main.go"},
88+ {"broken"}, {"**/*.GO"}, {"src/util/../main.go"}, {"/etc/hostname"}, {"src\\main.go"}, {"**/u*"}, {"*/*/*"}, {"src/**/*", "!src/util"},
89+ } {
90+ for _, follow := range []bool{false, true} {
91+ want := officialHash(t, script, ws, patterns, follow)
92+ got, err := hashFilesIn(t.Context(), ws, "/home/x", patterns, follow)
93+ if want.failed {
94+ if err == nil {
95+ t.Errorf("%q follow=%v: the script fails (%s), got %q", patterns, follow, want.msg, got)
96+ }
97+ continue
98+ }
99+ if err != nil || got != want.sum {
100+ t.Errorf("%q follow=%v: got %q (%v), script %q", patterns, follow, got, err, want.sum)
101+ }
102+ }
103+ }
104+}
105+
106+// A symbolic link that leaves the workspace is not followed, whatever the
107+// script does: the executor may run as root and the workspace is the user's.
108+func TestHashFilesDoesNotLeaveTheWorkspace(t *testing.T) {
109+ outside := t.TempDir()
110+ writeTree(t, outside, map[string]string{"secret": "s"})
111+ ws := t.TempDir()
112+ writeTree(t, ws, map[string]string{"a.txt": "alpha\n"})
113+ if err := os.Symlink(filepath.Join(outside, "secret"), filepath.Join(ws, "leak")); err != nil {
114+ t.Fatal(err)
115+ }
116+ if err := os.Symlink(outside, filepath.Join(ws, "leakdir")); err != nil {
117+ t.Fatal(err)
118+ }
119+ only, _ := hashFilesIn(t.Context(), ws, "/h", []string{"a.txt"}, false)
120+ for _, follow := range []bool{false, true} {
121+ got, err := hashFilesIn(t.Context(), ws, "/h", []string{"**"}, follow)
122+ if err != nil || got != only {
123+ t.Errorf("follow=%v: %q (%v), want the hash of a.txt alone %q", follow, got, err, only)
124+ }
125+ }
126+}
127+
128+// A matched symbolic link without a target fails the call, like the script.
129+func TestHashFilesBrokenLinkFails(t *testing.T) {
130+ ws := t.TempDir()
131+ if err := os.Symlink("missing", filepath.Join(ws, "broken")); err != nil {
132+ t.Fatal(err)
133+ }
134+ if _, err := hashFilesIn(t.Context(), ws, "/h", []string{"**"}, false); err == nil {
135+ t.Error("want an error")
136+ }
137+ if got, err := hashFilesIn(t.Context(), ws, "/h", []string{"**"}, true); err != nil || got != "" {
138+ t.Errorf("followed, the link is omitted: %q %v", got, err)
139+ }
140+}
141+
142+type officialResult struct {
143+ sum string
144+ failed bool
145+ msg string
146+}
147+
148+func officialHash(t *testing.T, script, ws string, patterns []string, follow bool) officialResult {
149+ t.Helper()
150+ cmd := exec.Command("node", script)
151+ cmd.Dir = ws
152+ cmd.Env = append(os.Environ(), "patterns="+strings.Join(patterns, "\n"))
153+ if follow {
154+ cmd.Env = append(cmd.Env, "followSymbolicLinks=true")
155+ }
156+ out, err := cmd.CombinedOutput()
157+ if err != nil {
158+ return officialResult{failed: true, msg: string(out)}
159+ }
160+ s := string(out)
161+ i := strings.Index(s, "__OUTPUT__")
162+ j := strings.LastIndex(s, "__OUTPUT__")
163+ if i < 0 || j <= i {
164+ t.Fatalf("no output: %s", s)
165+ }
166+ return officialResult{sum: s[i+10 : j]}
167+}
M internal/executor/job.go +1 -7
@@ -286,13 +286,7 @@ func (j *job) debug(num int, msg string) {
286286 func (j *job) evalEnv(env *ordered) template.Env {
287287 return template.Env{
288288 Contexts: j.state.Values(env),
289- Funcs: map[string]expr.Func{
290- // hashFiles is a CI-2 feature: the schema allows it in steps, so it
291- // parses, and fails when evaluated.
292- "hashFiles": func(context.Context, []expr.Value) (expr.Value, error) {
293- return nil, errors.New("not supported yet: hashFiles")
294- },
295- },
289+ Funcs: map[string]expr.Func{"hashFiles": j.hashFilesFunc()},
296290 }
297291 }
298292
@@ -286,13 +286,7 @@ func (j *job) debug(num int, msg string) {
286 func (j *job) evalEnv(env *ordered) template.Env {286 func (j *job) evalEnv(env *ordered) template.Env {
287 return template.Env{287 return template.Env{
288 Contexts: j.state.Values(env),288 Contexts: j.state.Values(env),
289- Funcs: map[string]expr.Func{289+ Funcs: map[string]expr.Func{"hashFiles": j.hashFilesFunc()},
290- // hashFiles is a CI-2 feature: the schema allows it in steps, so it
291- // parses, and fails when evaluated.
292- "hashFiles": func(context.Context, []expr.Value) (expr.Value, error) {
293- return nil, errors.New("not supported yet: hashFiles")
294- },
295- },
296 }290 }
297 }291 }
298 292

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close