mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-06 15:20:29 +00:00
Built-in actions that select files need to match what the JavaScript actions match. This ports @actions/glob 0.6.1 and the minimatch 3 subset it uses, as bundled in `actions/upload-artifact` v7. It only answers questions about paths; walking the files stays with the caller. Only malformed bracket expressions and some non-ASCII case folding on Windows can match differently. Co-authored-by: silverwind <[email protected]> Reviewed-on: https://gitea.com/gitea/runner/pulls/1342 Reviewed-by: silverwind <[email protected]> Co-authored-by: bircni <[email protected]>
270 lines
6.5 KiB
Go
270 lines
6.5 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package glob
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf16"
|
|
)
|
|
|
|
// pattern ports @actions/glob internal-pattern.ts.
|
|
type pattern struct {
|
|
negate bool
|
|
searchPath string
|
|
segments []string
|
|
trailingSeparator bool
|
|
implicit bool
|
|
rootLiteral string
|
|
mm []mmSegment
|
|
w flavour
|
|
}
|
|
|
|
type parseEnv struct {
|
|
w flavour
|
|
cwd string
|
|
home func() (string, error)
|
|
}
|
|
|
|
func newPattern(s string, implicit bool, env parseEnv) (*pattern, error) {
|
|
w := env.w
|
|
p := &pattern{implicit: implicit, w: w}
|
|
s = jsTrim(s)
|
|
for strings.HasPrefix(s, "!") {
|
|
p.negate = !p.negate
|
|
s = jsTrim(s[1:])
|
|
}
|
|
|
|
s, err := fixupPattern(s, env)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.segments = w.splitPath(s)
|
|
p.trailingSeparator = strings.HasSuffix(w.normalizeSeparators(s), w.sep())
|
|
s = w.safeTrimTrailingSeparator(s)
|
|
|
|
var searchSegments []string
|
|
for _, seg := range p.segments {
|
|
lit := getLiteral(seg, w)
|
|
if lit == "" {
|
|
break
|
|
}
|
|
searchSegments = append(searchSegments, lit)
|
|
}
|
|
p.searchPath = w.joinSegments(searchSegments)
|
|
p.rootLiteral = searchSegments[0]
|
|
|
|
if w.windows {
|
|
s = strings.ReplaceAll(s, `\`, "/")
|
|
}
|
|
p.mm, err = compileMinimatch(s, w.windows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func fixupPattern(s string, env parseEnv) (string, error) {
|
|
w := env.w
|
|
if s == "" {
|
|
return "", errors.New("pattern cannot be empty")
|
|
}
|
|
segments := w.splitPath(s)
|
|
for i, seg := range segments {
|
|
if lit := getLiteral(seg, w); (lit == "." && i != 0) || lit == ".." {
|
|
return "", fmt.Errorf("invalid pattern '%s': relative pathing '.' and '..' is not allowed", s)
|
|
}
|
|
}
|
|
if w.hasRoot(s) && getLiteral(segments[0], w) == "" {
|
|
return "", fmt.Errorf("invalid pattern '%s': root segment must not contain globs", s)
|
|
}
|
|
|
|
s = w.normalizeSeparators(s)
|
|
sep := w.sep()
|
|
var root string
|
|
var err error
|
|
switch {
|
|
case s == "." || strings.HasPrefix(s, "."+sep):
|
|
s = globEscape(env.cwd, w) + s[1:]
|
|
case s == "~" || strings.HasPrefix(s, "~"+sep):
|
|
var home string
|
|
if env.home != nil {
|
|
if home, err = env.home(); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
if home == "" {
|
|
return "", errors.New("unable to determine HOME directory")
|
|
}
|
|
if home = w.normalizeSeparators(home); !w.hasAbsoluteRoot(home) {
|
|
return "", fmt.Errorf("expected HOME directory to be a rooted path, actual '%s'", home)
|
|
}
|
|
s = globEscape(home, w) + s[1:]
|
|
case w.windows && (reDriveOnly.MatchString(s) || reDriveNoSep.MatchString(s)):
|
|
if root, err = w.ensureAbsoluteRoot(`C:\dummy-root`, s[:2], env.cwd); err != nil {
|
|
return "", err
|
|
}
|
|
if len(s) > 2 && !strings.HasSuffix(root, `\`) {
|
|
root += `\`
|
|
}
|
|
s = globEscape(root, w) + s[2:]
|
|
case w.windows && (s == `\` || reRelRootOnly.MatchString(s)):
|
|
if root, err = w.ensureAbsoluteRoot(`C:\dummy-root`, `\`, env.cwd); err != nil {
|
|
return "", err
|
|
}
|
|
if !strings.HasSuffix(root, `\`) {
|
|
root += `\`
|
|
}
|
|
s = globEscape(root, w) + s[1:]
|
|
default:
|
|
if s, err = w.ensureAbsoluteRoot(globEscape(env.cwd, w), s, env.cwd); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return w.normalizeSeparators(s), nil
|
|
}
|
|
|
|
func (p *pattern) match(itemPath string) MatchKind {
|
|
w := p.w
|
|
if p.segments[len(p.segments)-1] == "**" {
|
|
itemPath = w.normalizeSeparators(itemPath)
|
|
// Without the trailing separator minimatch would not match the directory preceding the globstar.
|
|
if !strings.HasSuffix(itemPath, w.sep()) && !p.implicit {
|
|
itemPath += w.sep()
|
|
}
|
|
} else {
|
|
itemPath = w.safeTrimTrailingSeparator(itemPath)
|
|
}
|
|
if w.windows {
|
|
itemPath = strings.ReplaceAll(itemPath, `\`, "/")
|
|
}
|
|
if !matchOne(splitRuns(itemPath, '/'), p.mm, false) {
|
|
return MatchNone
|
|
}
|
|
if p.trailingSeparator {
|
|
return MatchDirectory
|
|
}
|
|
return MatchAll
|
|
}
|
|
|
|
func (p *pattern) partialMatch(itemPath string) bool {
|
|
w := p.w
|
|
itemPath = w.safeTrimTrailingSeparator(itemPath)
|
|
// The toolkit tests roots with an unanchored regexp because minimatch mishandles them.
|
|
if w.dirname(itemPath) == itemPath {
|
|
if w.windows {
|
|
return strings.Contains(strings.ToUpper(itemPath), strings.ToUpper(p.rootLiteral))
|
|
}
|
|
return strings.Contains(itemPath, p.rootLiteral)
|
|
}
|
|
sep := byte('/')
|
|
if w.windows {
|
|
sep = '\\'
|
|
}
|
|
return matchOne(splitRuns(itemPath, sep), p.mm, true)
|
|
}
|
|
|
|
// globEscape ports Pattern.globEscape.
|
|
func globEscape(s string, w flavour) string {
|
|
if !w.windows {
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
}
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '[' && closesBeforeSlash(s[i+1:]) {
|
|
b.WriteString("[[]")
|
|
continue
|
|
}
|
|
b.WriteByte(s[i])
|
|
}
|
|
s = strings.ReplaceAll(b.String(), "?", "[?]")
|
|
return strings.ReplaceAll(s, "*", "[*]")
|
|
}
|
|
|
|
// closesBeforeSlash reports whether s matches the lookahead `[^/]+\]`.
|
|
func closesBeforeSlash(s string) bool {
|
|
for i := 0; i < len(s) && s[i] != '/'; i++ {
|
|
if s[i] == ']' && i > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getLiteral ports Pattern.getLiteral, returning "" when the segment contains a glob.
|
|
func getLiteral(segment string, w flavour) string {
|
|
seg := utf16.Encode([]rune(segment))
|
|
var lit []uint16
|
|
for i := 0; i < len(seg); i++ {
|
|
c := seg[i]
|
|
switch {
|
|
case c == '\\' && !w.windows && i+1 < len(seg):
|
|
i++
|
|
lit = append(lit, seg[i])
|
|
continue
|
|
case c == '*' || c == '?':
|
|
return ""
|
|
case c == '[' && i+1 < len(seg):
|
|
var set []uint16
|
|
closed := -1
|
|
for i2 := i + 1; i2 < len(seg); i2++ {
|
|
c2 := seg[i2]
|
|
if c2 == '\\' && !w.windows && i2+1 < len(seg) {
|
|
i2++
|
|
set = append(set, seg[i2])
|
|
continue
|
|
} else if c2 == ']' {
|
|
closed = i2
|
|
break
|
|
}
|
|
set = append(set, c2)
|
|
}
|
|
if closed >= 0 {
|
|
if len(set) > 1 {
|
|
return ""
|
|
}
|
|
if len(set) == 1 {
|
|
lit = append(lit, set...)
|
|
i = closed
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
lit = append(lit, c)
|
|
}
|
|
return string(utf16.Decode(lit))
|
|
}
|
|
|
|
func isJSWhitespace(r rune) bool {
|
|
switch r {
|
|
case '\t', '\n', '\v', '\f', '\r', ' ', 0xa0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff:
|
|
return true
|
|
}
|
|
return r >= 0x2000 && r <= 0x200a
|
|
}
|
|
|
|
// jsTrim matches String.prototype.trim, which differs from strings.TrimSpace on U+0085 and U+FEFF.
|
|
func jsTrim(s string) string {
|
|
return strings.TrimFunc(s, isJSWhitespace)
|
|
}
|
|
|
|
// splitRuns splits like JS s.split(/c+/).
|
|
func splitRuns(s string, c byte) []string {
|
|
parts := []string{}
|
|
start := 0
|
|
for i := 0; i < len(s); {
|
|
if s[i] != c {
|
|
i++
|
|
continue
|
|
}
|
|
parts = append(parts, s[start:i])
|
|
for i < len(s) && s[i] == c {
|
|
i++
|
|
}
|
|
start = i
|
|
}
|
|
return append(parts, s[start:])
|
|
}
|