diff --git a/internal/pkg/action/glob/glob.go b/internal/pkg/action/glob/glob.go new file mode 100644 index 00000000..5e0fd6a2 --- /dev/null +++ b/internal/pkg/action/glob/glob.go @@ -0,0 +1,239 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Package glob ports the pattern semantics of the JavaScript @actions/glob 0.6.1 package, as +// bundled in actions/upload-artifact, without walking a filesystem. Malformed bracket expressions +// and some non-ASCII case folding on Windows can match differently. +package glob + +import ( + "fmt" + "path" + "strings" + "sync" +) + +type Options struct { + Workdir string // absolute; relative patterns resolve against it + Home func() (string, error) // replaces ~, called once and only when a pattern uses it + Windows bool // Windows path flavour + ImplicitDescendants bool +} + +type MatchKind int + +const ( + MatchNone MatchKind = 0 + MatchDirectory MatchKind = 1 + MatchFile MatchKind = 2 + MatchAll = MatchDirectory | MatchFile +) + +type Globber struct { + patterns []*pattern + searchPaths []string +} + +// Parse parses newline separated patterns like DefaultGlobber.create. +func Parse(patterns string, o Options) (*Globber, error) { + w := flavour{o.Windows} + env := parseEnv{w: w, cwd: w.normalizeSeparators(o.Workdir)} + if o.Home != nil { + env.home = sync.OnceValues(o.Home) + } + if env.cwd == "" || !w.hasAbsoluteRoot(env.cwd) { + return nil, fmt.Errorf("workdir '%s' is not an absolute path", o.Workdir) + } + if o.Windows { + patterns = strings.ReplaceAll(patterns, "\r\n", "\n") + patterns = strings.ReplaceAll(patterns, "\r", "\n") + } + + var parsed []*pattern + for line := range strings.SplitSeq(patterns, "\n") { + line = jsTrim(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + p, err := newPattern(line, false, env) + if err != nil { + return nil, err + } + parsed = append(parsed, p) + } + + g := &Globber{searchPaths: searchPaths(parsed, w)} + for _, p := range parsed { + g.patterns = append(g.patterns, p) + if o.ImplicitDescendants && (p.trailingSeparator || p.segments[len(p.segments)-1] != "**") { + s := jsTrim(w.joinSegments(append(append([]string(nil), p.segments...), "**"))) + if p.negate { + s = "!" + s + } + implicit, err := newPattern(s, true, env) + if err != nil { + return nil, err + } + g.patterns = append(g.patterns, implicit) + } + } + return g, nil +} + +// SearchPaths returns the literal prefix of each include pattern, without duplicates and +// paths nested under another search path. +func (g *Globber) SearchPaths() []string { + return append([]string(nil), g.searchPaths...) +} + +// Match ORs in each include pattern and masks out each exclude pattern, in order. +func (g *Globber) Match(p string) MatchKind { + result := MatchNone + for _, pat := range g.patterns { + if pat.negate { + result &^= pat.match(p) + } else { + result |= pat.match(p) + } + } + return result +} + +// PartialMatch reports whether an include pattern may match descendants of p. +func (g *Globber) PartialMatch(p string) bool { + for _, pat := range g.patterns { + if !pat.negate && pat.partialMatch(p) { + return true + } + } + return false +} + +func searchPaths(patterns []*pattern, w flavour) []string { + key := func(p *pattern) string { + if w.windows { + return strings.ToUpper(p.searchPath) + } + return p.searchPath + } + const candidate, included = 1, 2 + seen := map[string]int{} + for _, p := range patterns { + if !p.negate { + seen[key(p)] = candidate + } + } + var result []string + for _, p := range patterns { + if p.negate { + continue + } + k := key(p) + if seen[k] == included { + continue + } + foundAncestor := false + for temp, parent := k, w.dirname(k); parent != temp; temp, parent = parent, w.dirname(parent) { + if seen[parent] != 0 { + foundAncestor = true + break + } + } + if !foundAncestor { + result = append(result, p.searchPath) + seen[k] = included + } + } + return result +} + +// LeastCommonAncestor ports getMultiPathLCA from upload-artifact's search.ts, which becomes the +// artifact root when there are several search paths. Segments compare case-sensitively there, +// even on Windows. +func LeastCommonAncestor(paths []string, windows bool) string { + switch len(paths) { + case 0: + return "" + case 1: + return paths[0] + } + w := flavour{windows} + sep := w.sep() + split := make([][]string, len(paths)) + smallest := -1 + for i, p := range paths { + if w.windows { + p = w.normalizeSeparators(p) + } else { + p = path.Clean(p) + } + split[i] = strings.Split(p, sep) + if smallest < 0 || len(split[i]) < smallest { + smallest = len(split[i]) + } + } + + var common []string + if strings.HasPrefix(paths[0], sep) { + common = append(common, sep) + } + for i := 0; i < smallest; i++ { + same := true + for _, s := range split[1:] { + if s[i] != split[0][i] { + same = false + break + } + } + if !same { + break + } + common = append(common, split[0][i]) + } + return w.nodeJoin(common) +} + +// nodeJoin ports Node's path.join for the already normalized segments LeastCommonAncestor produces. +func (w flavour) nodeJoin(parts []string) string { + var nonEmpty []string + for _, p := range parts { + if p != "" { + nonEmpty = append(nonEmpty, p) + } + } + if len(nonEmpty) == 0 { + return "." + } + joined := strings.Join(nonEmpty, w.sep()) + if !w.windows { + return path.Clean(joined) + } + first := nonEmpty[0] + needsReplace, slashCount := true, 0 + if w.isSep(first[0]) { + slashCount++ + if len(first) > 1 && w.isSep(first[1]) { + slashCount++ + if len(first) > 2 { + if w.isSep(first[2]) { + slashCount++ + } else { + needsReplace = false + } + } + } + } + if needsReplace { + for slashCount < len(joined) && w.isSep(joined[slashCount]) { + slashCount++ + } + if slashCount >= 2 { + joined = `\` + joined[slashCount:] + } + } + // path.win32.normalize turns a bare drive into the drive relative "C:.". + if reDriveOnly.MatchString(joined) { + return joined + "." + } + return joined +} diff --git a/internal/pkg/action/glob/glob_test.go b/internal/pkg/action/glob/glob_test.go new file mode 100644 index 00000000..cc76b917 --- /dev/null +++ b/internal/pkg/action/glob/glob_test.go @@ -0,0 +1,216 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package glob + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + posix = Options{Workdir: "/work/cwd-with-[and]", Home: home("/home/home-with-[and]")} + windows = Options{Workdir: `C:\work\cwd-with-[and]`, Home: home(`C:\Users\home-with-[and]`), Windows: true} +) + +func home(dir string) func() (string, error) { + return func() (string, error) { return dir, nil } +} + +func implicit(o Options) Options { + o.ImplicitDescendants = true + return o +} + +func TestHomeResolvedLazilyOnce(t *testing.T) { + calls := 0 + o := Options{Workdir: "/w", Home: func() (string, error) { calls++; return "/h", nil }} + _, err := Parse("~/a\n! ~/b", o) + require.NoError(t, err) + _, err = Parse("/a", o) + require.NoError(t, err) + assert.Equal(t, 1, calls) +} + +func TestMatch(t *testing.T) { + tests := []struct { + name string + patterns string + opts Options + want map[string]MatchKind + }{ + {"negate markers toggle", "/a/*.txt\n!!/a/two.txt\n!/a/one.txt\n!!!/a/three.txt", posix, map[string]MatchKind{ + "/a/two.txt": MatchAll, "/a/one.txt": MatchNone, "/a/three.txt": MatchNone, "/a/x.txt": MatchAll, + }}, + {"home is escaped", "~/m*", posix, map[string]MatchKind{ + "/home/home-with-[and]/match": MatchAll, "/home/home-with-[and]/not-match": MatchNone, "/home/home-with-a/match": MatchNone, + }}, + {"workdir is escaped", "m*", posix, map[string]MatchKind{ + "/work/cwd-with-[and]/match": MatchAll, "/work/cwd-with-[and]/not-match": MatchNone, + }}, + {"workdir is escaped on windows", "m*\nC:n*\n" + `\work\cwd-with-[[]and]\o*`, windows, map[string]MatchKind{ + `C:\work\cwd-with-[and]\match`: MatchAll, `C:\work\cwd-with-[and]\nope`: MatchAll, `C:\work\cwd-with-[and]\ok`: MatchAll, `C:\work\cwd-with-[and]\x`: MatchNone, + }}, + {"globstar matches the directory before it", "/foo/bar/**", posix, map[string]MatchKind{ + "/": MatchNone, "/foo": MatchNone, "/foo/bar": MatchAll, "/foo/bar/baz": MatchAll, + }}, + {"globstar matches root", "/**", posix, map[string]MatchKind{"/": MatchAll}}, + {"globstar matches root on windows", `C:\**`, windows, map[string]MatchKind{`C:\`: MatchAll}}, + {"case sensitive on posix", "/Foo/**/Baz", posix, map[string]MatchKind{ + "/Foo/Baz": MatchAll, "/Foo/bAZ": MatchNone, "/fOO/bar/Baz": MatchNone, + }}, + {"case insensitive on windows", "C:/Foo/**/Baz", windows, map[string]MatchKind{ + `C:\Foo\Baz`: MatchAll, `c:\Foo\bAZ`: MatchAll, "C:/fOO/bar/bAZ": MatchAll, `C:\Foo\Bar`: MatchNone, + }}, + {"leading dot segment", ".\n./foo\n.bar", posix, map[string]MatchKind{ + "/work/cwd-with-[and]": MatchAll, "/work/cwd-with-[and]/foo": MatchAll, "/work/cwd-with-[and]/.bar": MatchAll, + "/work/cwd-with-[and]/bar": MatchNone, "/work/cwd-with-[and]bar": MatchNone, + }}, + {"leading tilde segment", "~\n~/foo\n~bar", posix, map[string]MatchKind{ + "/home/home-with-[and]": MatchAll, "/home/home-with-[and]/foo": MatchAll, "/work/cwd-with-[and]/~bar": MatchAll, + "/home/home-with-[and]/bar": MatchNone, "/home/home-with-[and]bar": MatchNone, + }}, + {"windows relative roots", "C:\nC:foo\nD:\nD:bar\n" + `\work\cwd-with-[[]and]\baz`, windows, map[string]MatchKind{ + `C:\work\cwd-with-[and]`: MatchAll, `C:\work\cwd-with-[and]\foo`: MatchAll, `C:\foo`: MatchNone, + `D:\`: MatchDirectory, `D:\bar`: MatchAll, `D:\foo`: MatchNone, `C:\work\cwd-with-[and]\baz`: MatchAll, + }}, + {"windows unc", `\\srv\share\a\*`, windows, map[string]MatchKind{`\\srv\share\a\b`: MatchAll, `//SRV/share/a/b`: MatchAll, `\\srv\other\a\b`: MatchNone}}, + {"trailing separator matches directories only", "/foo/**/", posix, map[string]MatchKind{ + "/": MatchNone, "/foo/": MatchDirectory, "/foo/bar": MatchDirectory, "/foo/bar/baz": MatchDirectory, + }}, + {"backslash is a separator on windows", `C:\foo\`, windows, map[string]MatchKind{`C:\foo`: MatchDirectory}}, + {"backslash escapes on posix", `/foo\*/b\[a]r`, posix, map[string]MatchKind{"/foo*/b[a]r": MatchAll, "/fooo/bar": MatchNone}}, + {"interleaved excludes", "/**/*.proj\n/**/README.txt\n!/**/solution2/**\n/**/*.sln\n!/**/proj2/README.txt", posix, map[string]MatchKind{ + "/solution1/proj1/proj1.proj": MatchAll, "/solution1/proj1/README.txt": MatchAll, "/solution1/proj2/README.txt": MatchNone, + "/solution1/solution1.sln": MatchAll, "/solution2/proj1/proj1.proj": MatchNone, "/solution2/solution2.sln": MatchAll, + }}, + {"exclude directories", "C:/foo/**\n!C:/foo/**/", windows, map[string]MatchKind{ + `C:\`: MatchNone, `C:\foo`: MatchFile, `C:\foo\bar`: MatchFile, + }}, + {"dot is on, brace and extglob are off", "/a/*\n/b/{x,y}\n/c/+(x)\n/d/#e", posix, map[string]MatchKind{ + "/a/.hidden": MatchAll, "/a/.": MatchNone, "/b/{x,y}": MatchAll, "/b/x": MatchNone, "/c/+(x)": MatchAll, "/c/x": MatchNone, "/d/#e": MatchAll, + }}, + {"character classes", "/a/[!x]\n/b/[a-c]z\n/c/[z-a]\n/d/[]]", posix, map[string]MatchKind{ + "/a/y": MatchAll, "/a/x": MatchNone, "/b/bz": MatchAll, "/b/dz": MatchNone, "/c/[z-a]": MatchAll, "/c/z": MatchNone, "/d/]": MatchAll, + }}, + {"unclosed class splices undefined like minimatch 3", "C:/foo/b[\\!]r/b*", windows, map[string]MatchKind{ + "C:/foo/b[undefined/!]r/baz": MatchAll, + }}, + {"implicit descendants", "/foo\n/bar/**\n/dir/\n!/foo/skip", implicit(posix), map[string]MatchKind{ + "/foo": MatchAll, "/foo/a/b": MatchAll, "/foo/skip": MatchNone, "/foo/skip/x": MatchNone, + "/bar": MatchAll, "/dir": MatchDirectory, "/dir/file": MatchAll, + }}, + {"no implicit descendants", "/foo", posix, map[string]MatchKind{"/foo": MatchAll, "/foo/a": MatchNone}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g, err := Parse(tt.patterns, tt.opts) + require.NoError(t, err) + for p, want := range tt.want { + assert.Equal(t, want, g.Match(p), p) + } + }) + } +} + +func TestPartialMatch(t *testing.T) { + tests := []struct { + name string + patterns string + opts Options + want map[string]bool + }{ + {"skips excludes", "/search1/foo/**\n/search2/bar/**\n!/search2/bar/**\n!/search3/baz/**", posix, map[string]bool{ + "/search1": true, "/search1/foo": true, "/search2": true, "/search2/bar": true, "/search3": false, "/search3/bar": false, + }}, + {"root", "/foo/**", posix, map[string]bool{"/": true}}, + {"root on windows", "C:\\foo\\**\nd:\\foo\\**", windows, map[string]bool{`c:\`: true, `D:\`: true, `E:\`: false}}, + {"case", "/Foo/Bar/**/Baz", posix, map[string]bool{"/Foo": true, "/fOO": false}}, + {"case on windows", "C:/Foo/Bar/**/Baz", windows, map[string]bool{`C:\Foo`: true, `C:\fOO`: true, `C:\Other`: false}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g, err := Parse(tt.patterns, tt.opts) + require.NoError(t, err) + for p, want := range tt.want { + assert.Equal(t, want, g.PartialMatch(p), p) + } + }) + } +} + +func TestSearchPaths(t *testing.T) { + tests := []struct { + name string + patterns string + opts Options + want []string + }{ + {"omits excludes", "/search1/foo/**\n/search2/bar/**\n!/search3/baz/**", posix, []string{"/search1/foo", "/search2/bar"}}, + { + "omits descendants", "/search1/foo/**\n/search1/foo/bar/**\n/search2/foo/bar\n/search2/**\n/search3/foo/bar/**\n/search3/foo/bar/**", posix, + []string{"/search1/foo", "/search2", "/search3/foo/bar"}, + }, + { + "omits descendants on windows", "C:\\Search1\\Foo\\**\nC:\\sEARCH1\\fOO\\bar\\**\nC:\\sEARCH1\\foo\\bar\nC:\\Search2\\**\nC:\\Search3\\Foo\\Bar\\**\nC:\\sEARCH3\\fOO\\bAR\\**", windows, + []string{`C:\Search1\Foo`, `C:\Search2`, `C:\Search3\Foo\Bar`}, + }, + {"skips comments and blank lines", "# /nope\n\n /a/* \n", posix, []string{"/a"}}, + {"splits carriage returns on windows", "C:/a/*\r\nC:/b/*\rC:/c/*", windows, []string{`C:\a`, `C:\b`, `C:\c`}}, + { + "unescapes literal segments", "/foo/b[a]r/b*\n/x/b[*]r/b*\n/y/b[!]r/b*\n/z/b[]r/b*\n/q/b[aA]r/b*\n/e/b\\[a]r/b*\n/f/b[\\]]r/b*", posix, + []string{"/foo/bar", "/x/b*r", "/y/b!r", "/z/b[]r", "/q", "/e/b[a]r", "/f/b]r"}, + }, + {"unescapes literal segments on windows", "C:/foo/b\\[a]r/b*\nC:/x/b[?]r/b*", windows, []string{`C:\foo\b\ar`, `C:\x\b?r`}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g, err := Parse(tt.patterns, tt.opts) + require.NoError(t, err) + assert.Equal(t, tt.want, g.SearchPaths()) + }) + } +} + +func TestParseErrors(t *testing.T) { + tests := []struct { + name string + patterns string + opts Options + }{ + {"empty after negation", "!", posix}, + {"dot segment", "/a/./b", posix}, + {"dot dot segment", "a/../b", posix}, + {"glob in unc root", `\\srv\sh*re\a`, windows}, + {"tilde without home", "~/a", Options{Workdir: "/w"}}, + {"relative workdir", "a", Options{Workdir: "w"}}, + {"drive relative with unc workdir", "C:a", Options{Workdir: `\\srv\share`, Windows: true}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.patterns, tt.opts) + assert.Error(t, err) + }) + } +} + +func TestLeastCommonAncestor(t *testing.T) { + tests := []struct { + paths []string + windows bool + want string + }{ + {[]string{"/a/b/c", "/a/b/d", "/a/b"}, false, "/a/b"}, + {[]string{"/foo", "/bar"}, false, "/"}, + {[]string{"/a"}, false, "/a"}, + {[]string{`C:\a\b`, `C:\a\c`}, true, `C:\a`}, + {[]string{`C:\a`, `c:\a\b`}, true, "."}, + {[]string{`C:\a`, `C:\b`}, true, "C:."}, + {[]string{`\\srv\share\a`, `\\srv\share\b`}, true, `\srv\share`}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, LeastCommonAncestor(tt.paths, tt.windows), tt.paths) + } +} diff --git a/internal/pkg/action/glob/minimatch.go b/internal/pkg/action/glob/minimatch.go new file mode 100644 index 00000000..5b5b4f67 --- /dev/null +++ b/internal/pkg/action/glob/minimatch.go @@ -0,0 +1,534 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package glob + +import ( + "errors" + "slices" + "unicode" + "unicode/utf16" +) + +// This ports the subset of minimatch 3.1.5 that @actions/glob 0.6.1 uses: options dot, nobrace, +// noext, nocomment and nonegate, with nocase on Windows. Like JS regexps without the u flag it +// works on UTF-16 code units. + +const maxGlobstarRecursion = 200 + +type tokKind uint8 + +const ( + tokLit tokKind = iota + tokAny // [^/] + tokStar // [^/]*? + tokClass // [...] + tokNonEmpty // (?=.) + tokNotDot // (?!(?:^|\/)\.{1,2}(?:$|\/)) +) + +type unitRange struct{ lo, hi uint16 } + +type tok struct { + kind tokKind + ch uint16 + neg bool + ranges []unitRange +} + +type mmSegment struct { + globstar bool + magic bool + nocase bool + literal string + toks []tok +} + +type classItem struct { + ch uint16 + escaped bool +} + +func isReSpecial(c uint16) bool { + switch c { + case '(', ')', '.', '*', '{', '}', '+', '?', '[', ']', '^', '$', '\\', '!': + return true + } + return false +} + +func compileMinimatch(pattern string, nocase bool) ([]mmSegment, error) { + if len(utf16.Encode([]rune(pattern))) > 64*1024 { + return nil, errors.New("pattern is too long") + } + parts := splitRuns(jsTrim(pattern), '/') + segs := make([]mmSegment, len(parts)) + for i, part := range parts { + segs[i] = compileSegment(part, nocase) + } + return segs, nil +} + +func compileSegment(s string, nocase bool) mmSegment { + if s == "**" { + return mmSegment{globstar: true} + } + if s == "" { + return mmSegment{} + } + p := utf16.Encode([]rune(s)) + toks, magic := parseSegment(p, nocase) + if !magic { + return mmSegment{literal: globUnescape(s)} + } + return mmSegment{magic: true, nocase: nocase, toks: toks} +} + +// globUnescape replaces \x with x like s.replace(/\\(.)/g, '$1'); JS . excludes line terminators. +func globUnescape(s string) string { + u := utf16.Encode([]rune(s)) + out := make([]uint16, 0, len(u)) + for i := 0; i < len(u); i++ { + if u[i] == '\\' && i+1 < len(u) && !isLineTerminator(u[i+1]) { + i++ + } + out = append(out, u[i]) + } + return string(utf16.Decode(out)) +} + +// parseSegment ports minimatch's parse(), emitting tokens instead of a regexp source. +func parseSegment(p []uint16, nocase bool) (toks []tok, hasMagic bool) { + hasMagic = nocase + escaping := false + var stateChar uint16 + inClass := false + classStart := -1 + var class []classItem + lit := func(c uint16) { toks = append(toks, tok{kind: tokLit, ch: c}) } + clearStateChar := func() { + switch stateChar { + case 0: + return + case '*': + toks = append(toks, tok{kind: tokStar}) + hasMagic = true + case '?': + toks = append(toks, tok{kind: tokAny}) + hasMagic = true + default: + lit(stateChar) + } + stateChar = 0 + } + fallback := func(cs []uint16, closing bool) { + lit('[') + // parse() returns a non-array for "" and "**", so minimatch splices in the text "undefined". + if len(cs) == 0 || (len(cs) == 2 && cs[0] == '*' && cs[1] == '*') { + for _, c := range "undefined" { + lit(uint16(c)) + } + if closing { + lit(']') + } + inClass = false + return + } + subToks, subMagic := parseSegment(cs, nocase) + toks = append(toks, subToks...) + if closing { + lit(']') + } + hasMagic = hasMagic || subMagic + inClass = false + } + + for i := range p { + c := p[i] + if escaping && isReSpecial(c) { + if inClass { + class = append(class, classItem{c, true}) + } else { + lit(c) + } + escaping = false + continue + } + switch c { + case '\\': + clearStateChar() + escaping = true + case '?', '*', '+', '@', '!': + if inClass { + if c == '!' && i == classStart+1 { + c = '^' + } + class = append(class, classItem{c, false}) + continue + } + clearStateChar() + stateChar = c + clearStateChar() // noext + case '(': + if inClass { + class = append(class, classItem{c, false}) + } else { + lit(c) + } + case ')': + if inClass { + class = append(class, classItem{c, true}) + } else { + lit(c) + } + case '|': + if inClass { + class = append(class, classItem{c, true}) + } else { + lit(c) + } + escaping = false + case '[': + clearStateChar() + if inClass { + class = append(class, classItem{c, true}) + continue + } + inClass = true + classStart = i + class = class[:0] + case ']': + if i == classStart+1 || !inClass { + if inClass { + class = append(class, classItem{c, true}) + } else { + lit(c) + } + escaping = false + continue + } + t, ok := buildClass(class) + if !ok { + fallback(p[classStart+1:i], true) + continue + } + toks = append(toks, t) + hasMagic = true + inClass = false + default: + clearStateChar() + if inClass { + class = append(class, classItem{c, !escaping && isReSpecial(c) && c != '^'}) + } else { + lit(c) + } + escaping = false + } + } + + if inClass { + fallback(p[classStart+1:], false) + } + clearStateChar() + if escaping { + lit('\\') + } + + addPatternStart := len(toks) > 0 && (toks[0].kind == tokStar || toks[0].kind == tokAny || toks[0].kind == tokClass) + if len(toks) > 0 && hasMagic { + toks = append([]tok{{kind: tokNonEmpty}}, toks...) + } + if addPatternStart && p[0] != '.' { + toks = append([]tok{{kind: tokNotDot}}, toks...) + } + return toks, hasMagic +} + +// buildClass turns the class body minimatch emits into ranges, ok is false for an out of order range like [z-a], which matches literally. +func buildClass(items []classItem) (tok, bool) { + t := tok{kind: tokClass} + if len(items) > 0 && items[0].ch == '^' && !items[0].escaped { + t.neg = true + items = items[1:] + } + for i := 0; i < len(items); { + lo := items[i].ch + if i+2 < len(items) && items[i+1].ch == '-' && !items[i+1].escaped { + hi := items[i+2].ch + if lo > hi { + return t, false + } + t.ranges = append(t.ranges, unitRange{lo, hi}) + i += 3 + continue + } + t.ranges = append(t.ranges, unitRange{lo, lo}) + i++ + } + return t, true +} + +func isLineTerminator(c uint16) bool { + return c == '\n' || c == '\r' || c == 0x2028 || c == 0x2029 +} + +// canonicalize ports the ES Canonicalize() used by non-unicode case-insensitive regexps. +func canonicalize(c uint16) uint16 { + if utf16.IsSurrogate(rune(c)) { + return c + } + up := unicode.ToUpper(rune(c)) + if up > 0xffff || (c >= 128 && up < 128) { + return c + } + return uint16(up) +} + +func (t *tok) classMatch(c uint16, nocase bool) bool { + in := func(v uint16) bool { + for _, r := range t.ranges { + if v >= r.lo && v <= r.hi { + return true + } + } + return false + } + hit := in(c) + if !hit && nocase && !utf16.IsSurrogate(rune(c)) { + want := canonicalize(c) + for f := unicode.SimpleFold(rune(c)); f != rune(c); f = unicode.SimpleFold(f) { + if f <= 0xffff && canonicalize(uint16(f)) == want && in(uint16(f)) { + hit = true + break + } + } + } + return hit != t.neg +} + +func (s *mmSegment) match(f string) bool { + if !s.magic { + return f == s.literal + } + u := utf16.Encode([]rune(f)) + n := len(u) + 1 + memo := make([]int8, (len(s.toks)+1)*n) + var rec func(ti, pos int) bool + rec = func(ti, pos int) bool { + if ti == len(s.toks) { + return pos == len(u) + } + key := ti*n + pos + if memo[key] != 0 { + return memo[key] > 0 + } + t := &s.toks[ti] + hit := false + switch t.kind { + case tokLit: + hit = pos < len(u) && (u[pos] == t.ch || (s.nocase && canonicalize(u[pos]) == canonicalize(t.ch))) && rec(ti+1, pos+1) + case tokAny: + hit = pos < len(u) && u[pos] != '/' && rec(ti+1, pos+1) + case tokClass: + hit = pos < len(u) && t.classMatch(u[pos], s.nocase) && rec(ti+1, pos+1) + case tokStar: + for j := pos; !hit; j++ { + hit = rec(ti+1, j) + if j == len(u) || u[j] == '/' { + break + } + } + case tokNonEmpty: + hit = pos < len(u) && !isLineTerminator(u[pos]) && rec(ti+1, pos) + case tokNotDot: + hit = !dotSegmentAhead(u, pos) && rec(ti+1, pos) + } + memo[key] = -1 + if hit { + memo[key] = 1 + } + return hit + } + return rec(0, 0) +} + +// dotSegmentAhead evaluates (?:^|\/)\.{1,2}(?:$|\/) at pos. +func dotSegmentAhead(u []uint16, pos int) bool { + i := pos + if pos != 0 { + if i >= len(u) || u[i] != '/' { + return false + } + i++ + } + for dots := 0; dots < 2 && i < len(u) && u[i] == '.'; dots++ { + i++ + if i == len(u) || u[i] == '/' { + return true + } + } + return false +} + +// matchOne ports Minimatch.prototype.matchOne for the single pattern set nobrace produces. +func matchOne(file []string, pattern []mmSegment, partial bool) bool { + for i := range pattern { + if pattern[i].globstar { + return matchGlobstar(file, pattern, partial, 0, 0) + } + } + return matchOneFrom(file, pattern, partial, 0) +} + +func clampSlice(s []string, from, to int) []string { + to = min(to, len(s)) + from = min(from, to) + return s[from:to] +} + +func matchOneFrom(file []string, pattern []mmSegment, partial bool, fi int) bool { + fl, pl, pi := len(file), len(pattern), 0 + for ; fi < fl && pi < pl; fi, pi = fi+1, pi+1 { + if pattern[pi].globstar || !pattern[pi].match(file[fi]) { + return false + } + } + switch { + case fi == fl && pi == pl: + return true + case fi == fl: + return partial + case pi == pl: + return fi == fl-1 && file[fi] == "" + } + return false +} + +func isDotOrDotDot(f string) bool { + return f == "." || f == ".." +} + +func matchGlobstar(file []string, pattern []mmSegment, partial bool, fileIndex, patternIndex int) bool { + firstgs, lastgs := -1, -1 + for i := patternIndex; i < len(pattern); i++ { + if pattern[i].globstar { + firstgs = i + break + } + } + for i, seg := range slices.Backward(pattern) { + if seg.globstar { + lastgs = i + break + } + } + head := pattern[patternIndex:firstgs] + var body, tail []mmSegment + if partial { + body = pattern[firstgs+1:] + } else { + body = pattern[firstgs+1 : max(lastgs, firstgs+1)] + tail = pattern[lastgs+1:] + } + + if len(head) > 0 { + if !matchOneFrom(clampSlice(file, fileIndex, fileIndex+len(head)), head, partial, 0) { + return false + } + fileIndex += len(head) + } + + fileTailMatch := 0 + if len(tail) > 0 { + if len(tail)+fileIndex > len(file) { + return false + } + tailStart := len(file) - len(tail) + if matchOneFrom(file, tail, partial, tailStart) { + fileTailMatch = len(tail) + } else { + // a/**/* matching a/b/ + if file[len(file)-1] != "" || fileIndex+len(tail) == len(file) { + return false + } + tailStart-- + if !matchOneFrom(file, tail, partial, tailStart) { + return false + } + fileTailMatch = len(tail) + 1 + } + } + + if len(body) == 0 { + sawSome := fileTailMatch > 0 + for i := fileIndex; i < len(file)-fileTailMatch; i++ { + sawSome = true + if isDotOrDotDot(file[i]) { + return false + } + } + return partial || sawSome + } + + type section struct { + body []mmSegment + after int + } + sections := []section{{}} + nonGsParts := 0 + nonGsPartsSums := []int{0} + for _, b := range body { + if b.globstar { + nonGsPartsSums = append(nonGsPartsSums, nonGsParts) + sections = append(sections, section{}) + } else { + sections[len(sections)-1].body = append(sections[len(sections)-1].body, b) + nonGsParts++ + } + } + idx := len(sections) - 1 + fileLength := len(file) - fileTailMatch + for si := range sections { + sections[si].after = fileLength - (nonGsPartsSums[idx] + len(sections[si].body)) + idx-- + } + + // minimatch returns false (no match), null (no match, stop trying) or true. + const ( + triFalse = iota + triNull + triTrue + ) + var bodySections func(fileIndex, bodyIndex, depth int, sawTail bool) int + bodySections = func(fileIndex, bodyIndex, depth int, sawTail bool) int { + if bodyIndex >= len(sections) { + for i := fileIndex; i < len(file); i++ { + sawTail = true + if isDotOrDotDot(file[i]) { + return triFalse + } + } + if sawTail { + return triTrue + } + return triFalse + } + bs := sections[bodyIndex] + for ; fileIndex <= bs.after; fileIndex++ { + m := matchOneFrom(clampSlice(file, 0, fileIndex+len(bs.body)), bs.body, partial, fileIndex) + if m && depth < maxGlobstarRecursion { + if sub := bodySections(fileIndex+len(bs.body), bodyIndex+1, depth+1, sawTail); sub != triFalse { + return sub + } + } + if fileIndex < len(file) && isDotOrDotDot(file[fileIndex]) { + return triFalse + } + } + if partial { + return triTrue + } + return triNull + } + return bodySections(fileIndex, 0, 0, fileTailMatch > 0) == triTrue +} diff --git a/internal/pkg/action/glob/path.go b/internal/pkg/action/glob/path.go new file mode 100644 index 00000000..99b75e4d --- /dev/null +++ b/internal/pkg/action/glob/path.go @@ -0,0 +1,284 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package glob + +import ( + "errors" + "regexp" + "strings" +) + +// flavour selects Windows or POSIX path semantics; the helpers port @actions/glob internal-path-helper.ts. +type flavour struct{ windows bool } + +var ( + reUNCPrefix = regexp.MustCompile(`^\\\\+[^\\]`) + reBackslashRun = regexp.MustCompile(`\\\\+`) + reSlashRun = regexp.MustCompile(`//+`) + reDriveRoot = regexp.MustCompile(`^[A-Za-z]:\\$`) + reDriveAbs = regexp.MustCompile(`^[A-Za-z]:\\`) + reDrive = regexp.MustCompile(`^[A-Za-z]:`) + reDriveOnly = regexp.MustCompile(`^[A-Za-z]:$`) + reDriveRel = regexp.MustCompile(`^[A-Za-z]:[^\\/]|^[A-Za-z]:$`) + reRelRoot = regexp.MustCompile(`^\\$|^\\[^\\]`) + reRelRootOnly = regexp.MustCompile(`^\\[^\\]`) + reDriveNoSep = regexp.MustCompile(`^[A-Za-z]:[^\\]`) + reUNCRoot = regexp.MustCompile(`^\\\\[^\\]+(\\[^\\]+)?$`) + reUNCRootSlash = regexp.MustCompile(`^\\\\[^\\]+\\[^\\]+\\$`) +) + +func (w flavour) sep() string { + if w.windows { + return `\` + } + return "/" +} + +func (w flavour) isSep(c byte) bool { + return c == '/' || (w.windows && c == '\\') +} + +func (w flavour) normalizeSeparators(p string) string { + if w.windows { + p = strings.ReplaceAll(p, "/", `\`) + prefix := "" + if reUNCPrefix.MatchString(p) { + prefix = `\` + } + return prefix + reBackslashRun.ReplaceAllLiteralString(p, `\`) + } + return reSlashRun.ReplaceAllLiteralString(p, "/") +} + +func (w flavour) safeTrimTrailingSeparator(p string) string { + if p == "" { + return "" + } + p = w.normalizeSeparators(p) + if !strings.HasSuffix(p, w.sep()) || p == w.sep() || (w.windows && reDriveRoot.MatchString(p)) { + return p + } + return p[:len(p)-1] +} + +func (w flavour) hasAbsoluteRoot(p string) bool { + p = w.normalizeSeparators(p) + if w.windows { + return strings.HasPrefix(p, `\\`) || reDriveAbs.MatchString(p) + } + return strings.HasPrefix(p, "/") +} + +func (w flavour) hasRoot(p string) bool { + p = w.normalizeSeparators(p) + if w.windows { + return strings.HasPrefix(p, `\`) || reDrive.MatchString(p) + } + return strings.HasPrefix(p, "/") +} + +func (w flavour) dirname(p string) string { + p = w.safeTrimTrailingSeparator(p) + if w.windows && reUNCRoot.MatchString(p) { + return p + } + result := w.nodeDirname(p) + if w.windows && reUNCRootSlash.MatchString(result) { + result = w.safeTrimTrailingSeparator(result) + } + return result +} + +// ensureAbsoluteRoot resolves Windows drive and root relative paths against cwd like process.cwd(). +func (w flavour) ensureAbsoluteRoot(root, itemPath, cwd string) (string, error) { + if root == "" || itemPath == "" { + return "", errors.New("ensureAbsoluteRoot parameters must not be empty") + } + if w.hasAbsoluteRoot(itemPath) { + return itemPath, nil + } + if w.windows { + if reDriveRel.MatchString(itemPath) { + if !reDriveAbs.MatchString(cwd) { + return "", errors.New("expected current directory to start with an absolute drive root, actual '" + cwd + "'") + } + if strings.EqualFold(itemPath[:1], cwd[:1]) { + if len(itemPath) == 2 { + return itemPath[:1] + `:\` + cwd[3:], nil + } + if !strings.HasSuffix(cwd, `\`) { + cwd += `\` + } + return itemPath[:1] + `:\` + cwd[3:] + itemPath[2:], nil + } + return itemPath[:1] + `:\` + itemPath[2:], nil + } else if reRelRoot.MatchString(w.normalizeSeparators(itemPath)) { + if !reDriveAbs.MatchString(cwd) { + return "", errors.New("expected current directory to start with an absolute drive root, actual '" + cwd + "'") + } + return cwd[:1] + `:\` + itemPath[1:], nil + } + } + if !w.hasAbsoluteRoot(root) { + return "", errors.New("ensureAbsoluteRoot parameter 'root' must have an absolute root") + } + if !strings.HasSuffix(root, "/") && (!w.windows || !strings.HasSuffix(root, `\`)) { + root += w.sep() + } + return root + itemPath, nil +} + +// splitPath ports internal-path.ts Path(string).segments. +func (w flavour) splitPath(p string) []string { + p = w.safeTrimTrailingSeparator(p) + if !w.hasRoot(p) { + return strings.Split(p, w.sep()) + } + var segments []string + remaining := p + for dir := w.dirname(remaining); dir != remaining; dir = w.dirname(remaining) { + segments = append([]string{w.nodeBasename(remaining)}, segments...) + remaining = dir + } + return append([]string{remaining}, segments...) +} + +// joinSegments ports internal-path.ts Path(string[]).toString(). +func (w flavour) joinSegments(segments []string) string { + result := w.normalizeSeparators(segments[0]) + if w.hasRoot(result) { + result = w.safeTrimTrailingSeparator(result) + } + skipSlash := strings.HasSuffix(result, w.sep()) || (w.windows && reDriveOnly.MatchString(result)) + for _, s := range segments[1:] { + if skipSlash { + skipSlash = false + } else { + result += w.sep() + } + result += w.normalizeSeparators(s) + } + return result +} + +func isDeviceRoot(c byte) bool { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') +} + +// nodeDirname ports Node's path.posix.dirname and path.win32.dirname. +func (w flavour) nodeDirname(p string) string { + n := len(p) + if n == 0 { + return "." + } + if !w.windows { + hasRoot := p[0] == '/' + end := -1 + matchedSlash := true + for i := n - 1; i >= 1; i-- { + if p[i] == '/' { + if !matchedSlash { + end = i + break + } + } else { + matchedSlash = false + } + } + switch { + case end == -1 && hasRoot: + return "/" + case end == -1: + return "." + case hasRoot && end == 1: + return "//" + } + return p[:end] + } + if n == 1 { + if w.isSep(p[0]) { + return p + } + return "." + } + rootEnd, offset := -1, 0 + if w.isSep(p[0]) { + rootEnd, offset = 1, 1 + if w.isSep(p[1]) { + j := 2 + last := j + for j < n && !w.isSep(p[j]) { + j++ + } + if j < n && j != last { + last = j + for j < n && w.isSep(p[j]) { + j++ + } + if j < n && j != last { + last = j + for j < n && !w.isSep(p[j]) { + j++ + } + if j == n { + return p + } + if j != last { + rootEnd, offset = j+1, j+1 + } + } + } + } + } else if isDeviceRoot(p[0]) && p[1] == ':' { + rootEnd = 2 + if n > 2 && w.isSep(p[2]) { + rootEnd = 3 + } + offset = rootEnd + } + end := -1 + matchedSlash := true + for i := n - 1; i >= offset; i-- { + if w.isSep(p[i]) { + if !matchedSlash { + end = i + break + } + } else { + matchedSlash = false + } + } + if end == -1 { + if rootEnd == -1 { + return "." + } + end = rootEnd + } + return p[:end] +} + +// nodeBasename ports Node's path.posix.basename and path.win32.basename without the ext argument. +func (w flavour) nodeBasename(p string) string { + start := 0 + if w.windows && len(p) >= 2 && isDeviceRoot(p[0]) && p[1] == ':' { + start = 2 + } + from, end := start, -1 + matchedSlash := true + for i := len(p) - 1; i >= start; i-- { + if w.isSep(p[i]) { + if !matchedSlash { + from = i + 1 + break + } + } else if end == -1 { + matchedSlash = false + end = i + 1 + } + } + if end == -1 { + return "" + } + return p[from:end] +} diff --git a/internal/pkg/action/glob/pattern.go b/internal/pkg/action/glob/pattern.go new file mode 100644 index 00000000..960feae5 --- /dev/null +++ b/internal/pkg/action/glob/pattern.go @@ -0,0 +1,269 @@ +// 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:]) +}