Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
228 changes: 222 additions & 6 deletions src/util/builtinOperators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions test/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
19 changes: 0 additions & 19 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down