diff --git a/package.json b/package.json index 6abf1da..9e53f93 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,7 @@ "@casbin/expression-eval": "^5.3.0", "await-lock": "^2.0.1", "buffer": "^6.0.3", - "csv-parse": "^5.5.6", - "minimatch": "^10.2.1" + "csv-parse": "^5.5.6" }, "files": [ "lib", diff --git a/src/util/builtinOperators.ts b/src/util/builtinOperators.ts index 94e3414..8be2ea1 100644 --- a/src/util/builtinOperators.ts +++ b/src/util/builtinOperators.ts @@ -14,7 +14,6 @@ import * as rbac from '../rbac'; import { ip } from './ip'; -import { minimatch } from 'minimatch'; // regexMatch determines whether key1 matches the pattern of key2 in regular expression. function regexMatch(key1: string, key2: string): boolean { @@ -315,8 +314,217 @@ function ipMatchFunc(...args: any[]): boolean { return ipMatch(ip1, ip2); } +// Glob matching with the same rules as Go Casbin's util.GlobMatch +// (github.com/bmatcuk/doublestar/v4): `*` matches any run of non-`/` +// characters (also empty and dot-files), `?` one non-`/` character, `**` as +// a whole path segment zero or more directories, `[...]` / `[!...]` / +// `[^...]` a character class, `{a,b}` alternatives and `\` escapes the next +// character. A leading `!` is a literal character, not a negation. +// +// Matching is a dynamic program over (token, position), so it takes +// O(tokens * length) time and never backtracks. + +type GlobToken = + | { t: 'lit'; c: string } + | { t: 'one' } + | { t: 'cls'; neg: boolean; ranges: [string, string][] } + | { t: 'star' } // `*`: any run of non-`/` characters + | { t: 'dirs' } // `**/`: zero or more directories + | { t: 'any' } // `**` as the whole pattern: anything + | { t: 'tailAny' } // trailing `/**`: nothing, or `/` and anything + | { t: 'tailDirs' }; // trailing `/**/`: nothing, or `/` and directories + +const maxGlobAlternatives = 1024; + +// globExpandBraces expands `{a,b}` into separate patterns. +// Returns null for an unclosed `{` or too many alternatives. +function globExpandBraces(pattern: string): string[] | null { + let open = -1; + let depth = 0; + const commas: number[] = []; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '\\') { + i++; + } else if (c === '[') { + const close = pattern.indexOf(']', i + 2); + if (close !== -1) { + i = close; + } + } else if (c === '{') { + if (depth === 0) { + open = i; + } + depth++; + } else if (c === ',' && depth === 1) { + commas.push(i); + } else if (c === '}' && depth > 0) { + depth--; + if (depth === 0) { + const prefix = pattern.slice(0, open); + const suffix = pattern.slice(i + 1); + const bounds = [open, ...commas, i]; + const out: string[] = []; + for (let k = 0; k + 1 < bounds.length; k++) { + const expanded = globExpandBraces(prefix + pattern.slice(bounds[k] + 1, bounds[k + 1]) + suffix); + if (expanded === null || out.length + expanded.length > maxGlobAlternatives) { + return null; + } + out.push(...expanded); + } + return out; + } + } + } + return depth > 0 ? null : [pattern]; +} + +// globTokenize turns a pattern without braces into tokens. +// Returns null for a bad pattern. +function globTokenize(pattern: string): GlobToken[] | null { + const tokens: GlobToken[] = []; + const last = (): GlobToken | undefined => tokens[tokens.length - 1]; + const afterSlash = (): boolean => { + const l = last(); + return l !== undefined && l.t === 'lit' && l.c === '/'; + }; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '\\') { + i++; + if (i === pattern.length) { + return null; + } + tokens.push({ t: 'lit', c: pattern[i] }); + } else if (c === '*') { + let j = i; + while (pattern[j + 1] === '*') { + j++; + } + const segmentStart = i === 0 || pattern[i - 1] === '/'; + const segmentEnd = j + 1 === pattern.length || pattern[j + 1] === '/'; + if (j > i && segmentStart && segmentEnd) { + if (j + 2 < pattern.length || (j + 2 === pattern.length && !afterSlash())) { + // Repeated `**/` mean the same as one. + if (last()?.t !== 'dirs') { + tokens.push({ t: 'dirs' }); + } + j++; + } else if (j + 2 === pattern.length) { + // A trailing `/**/` also matches the parent without the slash. + tokens.pop(); + tokens.push({ t: 'tailDirs' }); + j++; + } else if (afterSlash()) { + // A trailing `/**` also matches the parent itself. + tokens.pop(); + tokens.push({ t: 'tailAny' }); + } else if (last()?.t === 'dirs') { + tokens[tokens.length - 1] = { t: 'any' }; + } else { + tokens.push({ t: 'any' }); + } + } else { + tokens.push({ t: 'star' }); + } + i = j; + } else if (c === '?') { + tokens.push({ t: 'one' }); + } else if (c === '[') { + let j = i + 1; + const neg = pattern[j] === '!' || pattern[j] === '^'; + if (neg) { + j++; + } + const chars: string[] = []; + const ranges: [string, string][] = []; + for (; j < pattern.length && pattern[j] !== ']'; j++) { + if (pattern[j] === '\\') { + j++; + if (j === pattern.length) { + return null; + } + } + chars.push(pattern[j]); + } + if (j === pattern.length || chars.length === 0) { + return null; + } + for (let k = 0; k < chars.length; k++) { + if (chars[k + 1] === '-' && k + 2 < chars.length) { + if (chars[k] > chars[k + 2]) { + return null; + } + ranges.push([chars[k], chars[k + 2]]); + k += 2; + } else { + ranges.push([chars[k], chars[k]]); + } + } + tokens.push({ t: 'cls', neg, ranges }); + i = j; + } else { + tokens.push({ t: 'lit', c }); + } + } + return tokens; +} + +function globMatchTokens(tokens: GlobToken[], s: string): boolean { + const n = s.length; + // next[k]: tokens after the current one match s.slice(k). + let next = new Uint8Array(n + 1); + next[n] = 1; + for (let t = tokens.length - 1; t >= 0; t--) { + const tok = tokens[t]; + const cur = new Uint8Array(n + 1); + let run = 0; // for `dirs`: s.slice(k) is a non-`/` run, `/`, then a match + for (let k = n; k >= 0; k--) { + const c = s[k]; + let ok = false; + switch (tok.t) { + case 'lit': + ok = k < n && c === tok.c && next[k + 1] === 1; + break; + case 'one': + ok = k < n && c !== '/' && next[k + 1] === 1; + break; + case 'cls': { + if (k < n && next[k + 1] === 1) { + const inClass = tok.ranges.some(([lo, hi]) => c >= lo && c <= hi); + ok = inClass !== tok.neg; + } + break; + } + case 'star': + ok = next[k] === 1 || (k < n && c !== '/' && cur[k + 1] === 1); + break; + case 'any': + ok = next[k] === 1 || (k < n && cur[k + 1] === 1); + break; + case 'dirs': + if (k < n) { + run = c === '/' ? cur[k + 1] : run; + } + ok = next[k] === 1 || (k < n && run === 1); + break; + case 'tailAny': + ok = k === n || c === '/'; + break; + case 'tailDirs': + ok = k === n || (c === '/' && (k + 1 === n || s[n - 1] === '/')); + break; + } + cur[k] = ok ? 1 : 0; + } + next = cur; + } + return next[0] === 1; +} + /** * Returns true if the specified `string` matches the given glob `pattern`. + * Same rules as Go Casbin's util.GlobMatch, see globTokenize. * * @param string String to match * @param pattern Glob pattern to use for matching. @@ -328,12 +536,20 @@ function ipMatchFunc(...args: any[]): boolean { * ``` */ function globMatch(string: string, pattern: string): boolean { - // The minimatch doesn't support the pattern starts with * - // See https://github.com/isaacs/minimatch/issues/195 - if (pattern[0] === '*' && pattern[1] === '/') { - pattern = pattern.substring(1); + const patterns = globExpandBraces(pattern); + if (patterns === null) { + return false; + } + for (const p of patterns) { + const tokens = globTokenize(p); + if (tokens === null) { + return false; + } + if (globMatchTokens(tokens, string)) { + return true; + } } - return minimatch(string, pattern); + return false; } // generateGFunction is the factory method of the g(_, _) function. diff --git a/test/util.test.ts b/test/util.test.ts index bce2f09..54e486b 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -314,6 +314,44 @@ test('test globMatch', () => { expect(util.globMatch('/prefix/subprefix/foobar', '*/foo/*')).toEqual(false); expect(util.globMatch('a.conf', '*.conf')).toEqual(true); + + // Same results as Go Casbin's util.GlobMatch (doublestar). + expect(util.globMatch('/admin/.env', '/admin/*')).toEqual(true); + expect(util.globMatch('/admin/', '/admin/*')).toEqual(true); + expect(util.globMatch('/files/.git/config', '/files/**')).toEqual(true); + expect(util.globMatch('/files', '/files/**')).toEqual(true); + expect(util.globMatch('/files/a/b/c.txt', '/files/**/*.txt')).toEqual(true); + expect(util.globMatch('/files/c.txt', '/files/**/*.txt')).toEqual(true); + expect(util.globMatch('/a/b', '!/c')).toEqual(false); + expect(util.globMatch('!/c', '!/c')).toEqual(true); + expect(util.globMatch('/a/b/', '/a/b')).toEqual(false); + expect(util.globMatch('/a/b', '/a/b/')).toEqual(false); + expect(util.globMatch('/a/b', '/a/?')).toEqual(true); + expect(util.globMatch('/a/bc', '/a/?')).toEqual(false); + expect(util.globMatch('/a/b', '/a/[ab]')).toEqual(true); + expect(util.globMatch('/a/b', '/a/[!ab]')).toEqual(false); + expect(util.globMatch('/a/x', '/a/[^ab]')).toEqual(true); + expect(util.globMatch('/a/b', '/a/{b,c}')).toEqual(true); + expect(util.globMatch('/a/d', '/a/{b,c}')).toEqual(false); + expect(util.globMatch('/a/+(b)', '/a/+(b)')).toEqual(true); + expect(util.globMatch('/a/b', '/a/+(b)')).toEqual(false); + expect(util.globMatch('/a/*', '/a/\\*')).toEqual(true); + expect(util.globMatch('/a/b', '/a/\\*')).toEqual(false); + expect(util.globMatch('/a.b', '/a?b')).toEqual(true); + expect(util.globMatch('/axb', '/a.b')).toEqual(false); + expect(util.globMatch('/a\nb', '/**')).toEqual(true); + expect(util.globMatch('/x/a\nb/c', '/x/**/c')).toEqual(true); + expect(util.globMatch('/a\nb', '/*')).toEqual(true); + expect(util.globMatch('/a/b/c', '/a/**/**/c')).toEqual(true); + expect(util.globMatch('/a/c', '/a/**/**/c')).toEqual(true); + // Long paths with many `**` do not backtrack. + const longPath = '/' + 'a/'.repeat(3000) + 'x'; + expect(util.globMatch(longPath, '/**/a/**/a/**/a/**/y')).toEqual(false); + expect(util.globMatch(longPath, '/**/a/**/a/**/a/**/x')).toEqual(true); + // A bad pattern matches nothing. + expect(util.globMatch('/a', '/[a')).toEqual(false); + expect(util.globMatch('/a', '/{a')).toEqual(false); + expect(util.globMatch('/a', '/a\\')).toEqual(false); }); test('test hasEval', () => { diff --git a/yarn.lock b/yarn.lock index 821654c..50e44e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1387,11 +1387,6 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -balanced-match@^4.0.2: - version "4.0.4" - resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz" - integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== - base64-js@^1.3.1: version "1.5.1" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" @@ -1425,13 +1420,6 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -brace-expansion@^5.0.5: - version "5.0.5" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz" - integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== - dependencies: - balanced-match "^4.0.2" - braces@^2.3.1: version "2.3.2" resolved "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz" @@ -4261,13 +4249,6 @@ mimic-fn@^2.1.0: resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -minimatch@^10.2.1: - version "10.2.5" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz" - integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== - dependencies: - brace-expansion "^5.0.5" - minimatch@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz"