Skip to content

Commit b100374

Browse files
committed
limit recursion for **, improve perf considerably
This limits the levels of recursion allowed when encountering multiple non-adjacent `**` portions of a pattern. Update `**` handling, with performance massively improved by limiting the recursive walk much more aggressively. When a `**` portion is present, the entire pattern is split up into sections. The head and tail first have to match, and then each subsequent portion is only tested in the part of the file where it might actually be found, taking advantage of the fact that non-globstar portions must always consume as many path portions as there are pattern portions. Fix: GHSA-7r86-cg39-jmmj Backported 0bf499a to v3
1 parent 26ffeaa commit b100374

2 files changed

Lines changed: 246 additions & 102 deletions

File tree

minimatch.js

Lines changed: 157 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ function Minimatch (pattern, options) {
142142
}
143143

144144
this.options = options
145+
this.maxGlobstarRecursion = options.maxGlobstarRecursion !== undefined
146+
? options.maxGlobstarRecursion : 200
145147
this.set = []
146148
this.pattern = pattern
147149
this.regexp = null
@@ -787,109 +789,173 @@ Minimatch.prototype.match = function match (f, partial) {
787789
// out of pattern, then that's fine, as long as all
788790
// the parts match.
789791
Minimatch.prototype.matchOne = function (file, pattern, partial) {
790-
var options = this.options
792+
if (pattern.indexOf(GLOBSTAR) !== -1) {
793+
return this._matchGlobstar(file, pattern, partial, 0, 0)
794+
}
795+
return this._matchOne(file, pattern, partial, 0, 0)
796+
}
791797

792-
this.debug('matchOne',
793-
{ 'this': this, file: file, pattern: pattern })
798+
Minimatch.prototype._matchGlobstar = function (file, pattern, partial, fileIndex, patternIndex) {
799+
var i
794800

795-
this.debug('matchOne', file.length, pattern.length)
801+
// find first globstar from patternIndex
802+
var firstgs = -1
803+
for (i = patternIndex; i < pattern.length; i++) {
804+
if (pattern[i] === GLOBSTAR) { firstgs = i; break }
805+
}
796806

797-
for (var fi = 0,
798-
pi = 0,
799-
fl = file.length,
800-
pl = pattern.length
801-
; (fi < fl) && (pi < pl)
802-
; fi++, pi++) {
803-
this.debug('matchOne loop')
804-
var p = pattern[pi]
805-
var f = file[fi]
807+
// find last globstar
808+
var lastgs = -1
809+
for (i = pattern.length - 1; i >= 0; i--) {
810+
if (pattern[i] === GLOBSTAR) { lastgs = i; break }
811+
}
806812

807-
this.debug(pattern, p, f)
813+
var head = pattern.slice(patternIndex, firstgs)
814+
var body = pattern.slice(firstgs + 1, lastgs)
815+
var tail = pattern.slice(lastgs + 1)
808816

809-
// should be impossible.
810-
// some invalid regexp stuff in the set.
811-
/* istanbul ignore if */
812-
if (p === false) return false
813-
814-
if (p === GLOBSTAR) {
815-
this.debug('GLOBSTAR', [pattern, p, f])
816-
817-
// "**"
818-
// a/**/b/**/c would match the following:
819-
// a/b/x/y/z/c
820-
// a/x/y/z/b/c
821-
// a/b/x/b/x/c
822-
// a/b/c
823-
// To do this, take the rest of the pattern after
824-
// the **, and see if it would match the file remainder.
825-
// If so, return success.
826-
// If not, the ** "swallows" a segment, and try again.
827-
// This is recursively awful.
828-
//
829-
// a/**/b/**/c matching a/b/x/y/z/c
830-
// - a matches a
831-
// - doublestar
832-
// - matchOne(b/x/y/z/c, b/**/c)
833-
// - b matches b
834-
// - doublestar
835-
// - matchOne(x/y/z/c, c) -> no
836-
// - matchOne(y/z/c, c) -> no
837-
// - matchOne(z/c, c) -> no
838-
// - matchOne(c, c) yes, hit
839-
var fr = fi
840-
var pr = pi + 1
841-
if (pr === pl) {
842-
this.debug('** at the end')
843-
// a ** at the end will just swallow the rest.
844-
// We have found a match.
845-
// however, it will not swallow /.x, unless
846-
// options.dot is set.
847-
// . and .. are *never* matched by **, for explosively
848-
// exponential reasons.
849-
for (; fi < fl; fi++) {
850-
if (file[fi] === '.' || file[fi] === '..' ||
851-
(!options.dot && file[fi].charAt(0) === '.')) return false
852-
}
853-
return true
817+
// check the head
818+
if (head.length) {
819+
var fileHead = file.slice(fileIndex, fileIndex + head.length)
820+
if (!this._matchOne(fileHead, head, partial, 0, 0)) {
821+
return false
822+
}
823+
fileIndex += head.length
824+
}
825+
826+
// check the tail
827+
var fileTailMatch = 0
828+
if (tail.length) {
829+
if (tail.length + fileIndex > file.length) return false
830+
831+
var tailStart = file.length - tail.length
832+
if (this._matchOne(file, tail, partial, tailStart, 0)) {
833+
fileTailMatch = tail.length
834+
} else {
835+
// affordance for stuff like a/**/* matching a/b/
836+
if (file[file.length - 1] !== '' ||
837+
fileIndex + tail.length === file.length) {
838+
return false
839+
}
840+
tailStart--
841+
if (!this._matchOne(file, tail, partial, tailStart, 0)) {
842+
return false
854843
}
844+
fileTailMatch = tail.length + 1
845+
}
846+
}
855847

856-
// ok, let's see if we can swallow whatever we can.
857-
while (fr < fl) {
858-
var swallowee = file[fr]
859-
860-
this.debug('\nglobstar while', file, fr, pattern, pr, swallowee)
861-
862-
// XXX remove this slice. Just pass the start index.
863-
if (this.matchOne(file.slice(fr), pattern.slice(pr), partial)) {
864-
this.debug('globstar found match!', fr, fl, swallowee)
865-
// found a match.
866-
return true
867-
} else {
868-
// can't swallow "." or ".." ever.
869-
// can only swallow ".foo" when explicitly asked.
870-
if (swallowee === '.' || swallowee === '..' ||
871-
(!options.dot && swallowee.charAt(0) === '.')) {
872-
this.debug('dot detected!', file, fr, pattern, pr)
873-
break
874-
}
875-
876-
// ** swallows a segment, and continue.
877-
this.debug('globstar swallow a segment, and continue')
878-
fr++
879-
}
848+
// if body is empty (single ** between head and tail)
849+
if (!body.length) {
850+
var sawSome = !!fileTailMatch
851+
for (i = fileIndex; i < file.length - fileTailMatch; i++) {
852+
var f = String(file[i])
853+
sawSome = true
854+
if (f === '.' || f === '..' ||
855+
(!this.options.dot && f.charAt(0) === '.')) {
856+
return false
880857
}
858+
}
859+
return sawSome
860+
}
861+
862+
// split body into segments at each GLOBSTAR
863+
var bodySegments = [[[], 0]]
864+
var currentBody = bodySegments[0]
865+
var nonGsParts = 0
866+
var nonGsPartsSums = [0]
867+
for (var bi = 0; bi < body.length; bi++) {
868+
var b = body[bi]
869+
if (b === GLOBSTAR) {
870+
nonGsPartsSums.push(nonGsParts)
871+
currentBody = [[], 0]
872+
bodySegments.push(currentBody)
873+
} else {
874+
currentBody[0].push(b)
875+
nonGsParts++
876+
}
877+
}
878+
879+
var idx = bodySegments.length - 1
880+
var fileLength = file.length - fileTailMatch
881+
for (var si = 0; si < bodySegments.length; si++) {
882+
bodySegments[si][1] = fileLength -
883+
(nonGsPartsSums[idx--] + bodySegments[si][0].length)
884+
}
881885

882-
// no match was found.
883-
// However, in partial mode, we can't say this is necessarily over.
884-
// If there's more *pattern* left, then
885-
/* istanbul ignore if */
886-
if (partial) {
887-
// ran out of file
888-
this.debug('\n>>> no match, partial?', file, fr, pattern, pr)
889-
if (fr === fl) return true
886+
return !!this._matchGlobStarBodySections(
887+
file, bodySegments, fileIndex, 0, partial, 0, !!fileTailMatch
888+
)
889+
}
890+
891+
// return false for "nope, not matching"
892+
// return null for "not matching, cannot keep trying"
893+
Minimatch.prototype._matchGlobStarBodySections = function (
894+
file, bodySegments, fileIndex, bodyIndex, partial, globStarDepth, sawTail
895+
) {
896+
var bs = bodySegments[bodyIndex]
897+
if (!bs) {
898+
// just make sure there are no bad dots
899+
for (var i = fileIndex; i < file.length; i++) {
900+
sawTail = true
901+
var f = file[i]
902+
if (f === '.' || f === '..' ||
903+
(!this.options.dot && f.charAt(0) === '.')) {
904+
return false
890905
}
906+
}
907+
return sawTail
908+
}
909+
910+
var body = bs[0]
911+
var after = bs[1]
912+
while (fileIndex <= after) {
913+
var m = this._matchOne(
914+
file.slice(0, fileIndex + body.length),
915+
body,
916+
partial,
917+
fileIndex,
918+
0
919+
)
920+
// if limit exceeded, no match. intentional false negative,
921+
// acceptable break in correctness for security.
922+
if (m && globStarDepth < this.maxGlobstarRecursion) {
923+
var sub = this._matchGlobStarBodySections(
924+
file, bodySegments,
925+
fileIndex + body.length, bodyIndex + 1,
926+
partial, globStarDepth + 1, sawTail
927+
)
928+
if (sub !== false) {
929+
return sub
930+
}
931+
}
932+
var f = file[fileIndex]
933+
if (f === '.' || f === '..' ||
934+
(!this.options.dot && f.charAt(0) === '.')) {
891935
return false
892936
}
937+
fileIndex++
938+
}
939+
return null
940+
}
941+
942+
Minimatch.prototype._matchOne = function (file, pattern, partial, fileIndex, patternIndex) {
943+
var fi, pi, fl, pl
944+
for (
945+
fi = fileIndex, pi = patternIndex, fl = file.length, pl = pattern.length
946+
; (fi < fl) && (pi < pl)
947+
; fi++, pi++
948+
) {
949+
this.debug('matchOne loop')
950+
var p = pattern[pi]
951+
var f = file[fi]
952+
953+
this.debug(pattern, p, f)
954+
955+
// should be impossible.
956+
// some invalid regexp stuff in the set.
957+
/* istanbul ignore if */
958+
if (p === false || p === GLOBSTAR) return false
893959

894960
// something other than **
895961
// non-magic patterns just have to match exactly
@@ -906,17 +972,6 @@ Minimatch.prototype.matchOne = function (file, pattern, partial) {
906972
if (!hit) return false
907973
}
908974

909-
// Note: ending in / means that we'll get a final ""
910-
// at the end of the pattern. This can only match a
911-
// corresponding "" at the end of the file.
912-
// If the file ends in /, then it can only match a
913-
// a pattern that ends in /, unless the pattern just
914-
// doesn't have any more for it. But, a/b/ should *not*
915-
// match "a/b/*", even though "" matches against the
916-
// [^/]*? pattern, except in partial mode, where it might
917-
// simply not be reached yet.
918-
// However, a/b/ should still satisfy a/*
919-
920975
// now either we fell off the end of the pattern, or we're done.
921976
if (fi === fl && pi === pl) {
922977
// ran out of pattern and filename at the same time.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
const t = require('tap')
2+
const minimatch = require('../minimatch.js')
3+
const performance = require('perf_hooks').performance
4+
5+
t.test('GHSA-7r86-cg39-jmmj', async t => {
6+
const k = 50
7+
const pattern =
8+
Array.from({ length: k }, () => '**/a').join('/') + '/b/**'
9+
const patha = Array(100).fill('a').join('/') + '/a'
10+
const pathb = Array(100).fill('a').join('/') + '/b/c/d/.e/a/b'
11+
t.comment({ patha, pathb, pattern })
12+
13+
const starta = performance.now()
14+
t.equal(minimatch(patha, pattern), false)
15+
const dura = performance.now() - starta
16+
t.ok(dura < 1000, 'should take less than 1s to find mismatch', {
17+
found: dura,
18+
wanted: '<1000',
19+
})
20+
21+
const startb = performance.now()
22+
t.equal(minimatch(pathb, pattern, { dot: true }), true)
23+
const durb = performance.now() - startb
24+
t.comment({ dura, durb })
25+
t.ok(durb < 1000, 'should take less than 1s to find match', {
26+
found: durb,
27+
wanted: '<1000',
28+
})
29+
30+
const startc = performance.now()
31+
t.equal(minimatch(pathb, pattern), false)
32+
const durc = performance.now() - startc
33+
t.comment({ dura, durb, durc })
34+
t.ok(durc < 1000, 'should take less than 1s to find dot mismatch', {
35+
found: durc,
36+
wanted: '<1000',
37+
})
38+
})
39+
40+
t.test('alphabetical', async t => {
41+
const alphabet = 'abcdefghijklmnopqrstuvwxyz'.repeat(5)
42+
const pattern = '**/' + alphabet.split('').join('/**/') + '/**'
43+
const exclude = (c) =>
44+
alphabet.split('').filter(char => c != char)
45+
const path =
46+
alphabet
47+
.split('')
48+
.map(c => exclude(c))
49+
.reduce((set, c) => set.concat(c), [])
50+
.join('/') +
51+
'/' +
52+
exclude('a').concat('a').join('/')
53+
t.comment(path, pattern)
54+
const start = performance.now()
55+
t.equal(minimatch(path, pattern, { maxGlobstarRecursion: 30 }), false)
56+
t.equal(minimatch(path, pattern), true)
57+
const dur = performance.now() - start
58+
t.comment('alphabet time', dur)
59+
})
60+
61+
t.test('tail handling 1', async t => {
62+
const pattern = '.x/**/*/*/**'
63+
const match = '.x/.y/.z/'
64+
const nomatch = '.x/.y/.z'
65+
t.equal(minimatch(match, pattern, { dot: true }), true)
66+
t.equal(minimatch(nomatch, pattern, { dot: true }), false)
67+
})
68+
69+
t.test('tail handling 2', async t => {
70+
const pattern = '.x/**/**/*'
71+
const match = '.x/.y/.z/'
72+
const nomatch = '.x/'
73+
t.equal(minimatch(match, pattern, { dot: true }), true)
74+
t.equal(minimatch(nomatch, pattern, { dot: true }), false, {
75+
file: nomatch,
76+
pattern,
77+
})
78+
})
79+
80+
t.test('head/tail edge cases', async t => {
81+
// head mismatch: head 'a' does not match file starting with 'x'
82+
t.equal(minimatch('x/c', 'a/**/c'), false)
83+
// tail direct match: tail 'a' matches file[last] on first try
84+
t.equal(minimatch('b/a', '**/a'), true)
85+
// tail fallback failure: file ends in '/' but segment before '' is not 'a'
86+
t.equal(minimatch('b/c/', '**/a'), false)
87+
// tail too long: head + tail longer than entire file
88+
t.equal(minimatch('a', 'a/**/b/c'), false)
89+
})

0 commit comments

Comments
 (0)