detached 33a243c

hashfiles.go RawHistoryFolder

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

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