Skip to content

Commit 79e4447

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 v5
1 parent 85ec0ff commit 79e4447

2 files changed

Lines changed: 239 additions & 101 deletions

File tree

minimatch.js

Lines changed: 150 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ class Minimatch {
168168
if (!options) options = {}
169169

170170
this.options = options
171+
this.maxGlobstarRecursion = options.maxGlobstarRecursion !== undefined
172+
? options.maxGlobstarRecursion : 200
171173
this.set = []
172174
this.pattern = pattern
173175
this.windowsPathsNoEscape = !!options.windowsPathsNoEscape ||
@@ -255,114 +257,172 @@ class Minimatch {
255257
// out of pattern, then that's fine, as long as all
256258
// the parts match.
257259
matchOne (file, pattern, partial) {
258-
var options = this.options
260+
if (pattern.indexOf(GLOBSTAR) !== -1) {
261+
return this._matchGlobstar(file, pattern, partial, 0, 0)
262+
}
263+
return this._matchOne(file, pattern, partial, 0, 0)
264+
}
259265

260-
this.debug('matchOne',
261-
{ 'this': this, file: file, pattern: pattern })
266+
_matchGlobstar (file, pattern, partial, fileIndex, patternIndex) {
267+
// find first globstar from patternIndex
268+
let firstgs = -1
269+
for (let i = patternIndex; i < pattern.length; i++) {
270+
if (pattern[i] === GLOBSTAR) { firstgs = i; break }
271+
}
262272

263-
this.debug('matchOne', file.length, pattern.length)
273+
// find last globstar
274+
let lastgs = -1
275+
for (let i = pattern.length - 1; i >= 0; i--) {
276+
if (pattern[i] === GLOBSTAR) { lastgs = i; break }
277+
}
264278

265-
for (var fi = 0,
266-
pi = 0,
267-
fl = file.length,
268-
pl = pattern.length
269-
; (fi < fl) && (pi < pl)
270-
; fi++, pi++) {
271-
this.debug('matchOne loop')
272-
var p = pattern[pi]
273-
var f = file[fi]
279+
const head = pattern.slice(patternIndex, firstgs)
280+
const body = pattern.slice(firstgs + 1, lastgs)
281+
const tail = pattern.slice(lastgs + 1)
274282

275-
this.debug(pattern, p, f)
283+
// check the head
284+
if (head.length) {
285+
const fileHead = file.slice(fileIndex, fileIndex + head.length)
286+
if (!this._matchOne(fileHead, head, partial, 0, 0)) {
287+
return false
288+
}
289+
fileIndex += head.length
290+
}
276291

277-
// should be impossible.
278-
// some invalid regexp stuff in the set.
279-
/* istanbul ignore if */
280-
if (p === false) return false
281-
282-
if (p === GLOBSTAR) {
283-
this.debug('GLOBSTAR', [pattern, p, f])
284-
285-
// "**"
286-
// a/**/b/**/c would match the following:
287-
// a/b/x/y/z/c
288-
// a/x/y/z/b/c
289-
// a/b/x/b/x/c
290-
// a/b/c
291-
// To do this, take the rest of the pattern after
292-
// the **, and see if it would match the file remainder.
293-
// If so, return success.
294-
// If not, the ** "swallows" a segment, and try again.
295-
// This is recursively awful.
296-
//
297-
// a/**/b/**/c matching a/b/x/y/z/c
298-
// - a matches a
299-
// - doublestar
300-
// - matchOne(b/x/y/z/c, b/**/c)
301-
// - b matches b
302-
// - doublestar
303-
// - matchOne(x/y/z/c, c) -> no
304-
// - matchOne(y/z/c, c) -> no
305-
// - matchOne(z/c, c) -> no
306-
// - matchOne(c, c) yes, hit
307-
var fr = fi
308-
var pr = pi + 1
309-
if (pr === pl) {
310-
this.debug('** at the end')
311-
// a ** at the end will just swallow the rest.
312-
// We have found a match.
313-
// however, it will not swallow /.x, unless
314-
// options.dot is set.
315-
// . and .. are *never* matched by **, for explosively
316-
// exponential reasons.
317-
for (; fi < fl; fi++) {
318-
if (file[fi] === '.' || file[fi] === '..' ||
319-
(!options.dot && file[fi].charAt(0) === '.')) return false
320-
}
321-
return true
292+
// check the tail
293+
let fileTailMatch = 0
294+
if (tail.length) {
295+
if (tail.length + fileIndex > file.length) return false
296+
297+
const tailStart = file.length - tail.length
298+
if (this._matchOne(file, tail, partial, tailStart, 0)) {
299+
fileTailMatch = tail.length
300+
} else {
301+
// affordance for stuff like a/**/* matching a/b/
302+
if (file[file.length - 1] !== '' ||
303+
fileIndex + tail.length === file.length) {
304+
return false
305+
}
306+
if (!this._matchOne(file, tail, partial, tailStart - 1, 0)) {
307+
return false
322308
}
309+
fileTailMatch = tail.length + 1
310+
}
311+
}
323312

324-
// ok, let's see if we can swallow whatever we can.
325-
while (fr < fl) {
326-
var swallowee = file[fr]
313+
// if body is empty (single ** between head and tail)
314+
if (!body.length) {
315+
let sawSome = !!fileTailMatch
316+
for (let i = fileIndex; i < file.length - fileTailMatch; i++) {
317+
const f = String(file[i])
318+
sawSome = true
319+
if (f === '.' || f === '..' ||
320+
(!this.options.dot && f.charAt(0) === '.')) {
321+
return false
322+
}
323+
}
324+
return sawSome
325+
}
327326

328-
this.debug('\nglobstar while', file, fr, pattern, pr, swallowee)
327+
// split body into segments at each GLOBSTAR
328+
const bodySegments = [[[], 0]]
329+
let currentBody = bodySegments[0]
330+
let nonGsParts = 0
331+
const nonGsPartsSums = [0]
332+
for (const b of body) {
333+
if (b === GLOBSTAR) {
334+
nonGsPartsSums.push(nonGsParts)
335+
currentBody = [[], 0]
336+
bodySegments.push(currentBody)
337+
} else {
338+
currentBody[0].push(b)
339+
nonGsParts++
340+
}
341+
}
329342

330-
// XXX remove this slice. Just pass the start index.
331-
if (this.matchOne(file.slice(fr), pattern.slice(pr), partial)) {
332-
this.debug('globstar found match!', fr, fl, swallowee)
333-
// found a match.
334-
return true
335-
} else {
336-
// can't swallow "." or ".." ever.
337-
// can only swallow ".foo" when explicitly asked.
338-
if (swallowee === '.' || swallowee === '..' ||
339-
(!options.dot && swallowee.charAt(0) === '.')) {
340-
this.debug('dot detected!', file, fr, pattern, pr)
341-
break
342-
}
343-
344-
// ** swallows a segment, and continue.
345-
this.debug('globstar swallow a segment, and continue')
346-
fr++
347-
}
343+
let idx = bodySegments.length - 1
344+
const fileLength = file.length - fileTailMatch
345+
for (const b of bodySegments) {
346+
b[1] = fileLength - (nonGsPartsSums[idx--] + b[0].length)
347+
}
348+
349+
return !!this._matchGlobStarBodySections(
350+
file, bodySegments, fileIndex, 0, partial, 0, !!fileTailMatch
351+
)
352+
}
353+
354+
// return false for "nope, not matching"
355+
// return null for "not matching, cannot keep trying"
356+
_matchGlobStarBodySections (
357+
file, bodySegments, fileIndex, bodyIndex, partial, globStarDepth, sawTail
358+
) {
359+
const bs = bodySegments[bodyIndex]
360+
if (!bs) {
361+
// just make sure there are no bad dots
362+
for (let i = fileIndex; i < file.length; i++) {
363+
sawTail = true
364+
const f = file[i]
365+
if (f === '.' || f === '..' ||
366+
(!this.options.dot && f.charAt(0) === '.')) {
367+
return false
348368
}
369+
}
370+
return sawTail
371+
}
349372

350-
// no match was found.
351-
// However, in partial mode, we can't say this is necessarily over.
352-
// If there's more *pattern* left, then
353-
/* istanbul ignore if */
354-
if (partial) {
355-
// ran out of file
356-
this.debug('\n>>> no match, partial?', file, fr, pattern, pr)
357-
if (fr === fl) return true
373+
const [body, after] = bs
374+
while (fileIndex <= after) {
375+
const m = this._matchOne(
376+
file.slice(0, fileIndex + body.length),
377+
body,
378+
partial,
379+
fileIndex,
380+
0
381+
)
382+
// if limit exceeded, no match. intentional false negative,
383+
// acceptable break in correctness for security.
384+
if (m && globStarDepth < this.maxGlobstarRecursion) {
385+
const sub = this._matchGlobStarBodySections(
386+
file, bodySegments,
387+
fileIndex + body.length, bodyIndex + 1,
388+
partial, globStarDepth + 1, sawTail
389+
)
390+
if (sub !== false) {
391+
return sub
358392
}
393+
}
394+
const f = file[fileIndex]
395+
if (f === '.' || f === '..' ||
396+
(!this.options.dot && f.charAt(0) === '.')) {
359397
return false
360398
}
399+
fileIndex++
400+
}
401+
return null
402+
}
403+
404+
_matchOne (file, pattern, partial, fileIndex, patternIndex) {
405+
let fi, pi, fl, pl
406+
for (
407+
fi = fileIndex, pi = patternIndex, fl = file.length, pl = pattern.length
408+
; (fi < fl) && (pi < pl)
409+
; fi++, pi++
410+
) {
411+
this.debug('matchOne loop')
412+
const p = pattern[pi]
413+
const f = file[fi]
414+
415+
this.debug(pattern, p, f)
416+
417+
// should be impossible.
418+
// some invalid regexp stuff in the set.
419+
/* istanbul ignore if */
420+
if (p === false || p === GLOBSTAR) return false
361421

362422
// something other than **
363423
// non-magic patterns just have to match exactly
364424
// patterns with magic have been turned into regexps.
365-
var hit
425+
let hit
366426
if (typeof p === 'string') {
367427
hit = f === p
368428
this.debug('string match', p, f, hit)
@@ -374,17 +434,6 @@ class Minimatch {
374434
if (!hit) return false
375435
}
376436

377-
// Note: ending in / means that we'll get a final ""
378-
// at the end of the pattern. This can only match a
379-
// corresponding "" at the end of the file.
380-
// If the file ends in /, then it can only match a
381-
// a pattern that ends in /, unless the pattern just
382-
// doesn't have any more for it. But, a/b/ should *not*
383-
// match "a/b/*", even though "" matches against the
384-
// [^/]*? pattern, except in partial mode, where it might
385-
// simply not be reached yet.
386-
// However, a/b/ should still satisfy a/*
387-
388437
// now either we fell off the end of the pattern, or we're done.
389438
if (fi === fl && pi === pl) {
390439
// 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)