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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 286 | 286 | func (j *job) evalEnv(env *ordered) template.Env { | |
| 287 | 287 | return template.Env{ | |
| 288 | 288 | 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()}, | |
| 296 | 290 | } | |
| 297 | 291 | } | |
| 298 | 292 | ||
| @@ -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 | ||||