fix: treat a leading ? in an extglob body as a wildcard - #194
fix: treat a leading ? in an extglob body as a wildcard#194maximilliangrand wants to merge 1 commit into
? in an extglob body as a wildcard#194Conversation
`@(?)`, `*(?)`, `+(?)`, `?(?)` and `@(?ar|zip)` were not parsed as
extglobs at all. The `?`, `+`, `@` and `*` handlers declined to open an
extglob whenever the character after `(` was `?`, and a `?` immediately
following an extglob's opening paren was escaped to a literal `\?`. The
result was a regex requiring a literal `@`/`?`, so `isMatch('a', '@(?)')`
returned false where bash and minimatch both match.
The `!` handler already implemented the correct rule: only decline when
the `?` starts a regex group (`(?:`, `(?=`, `(?!`, `(?<`). That rule is
extracted into `isExtglobOpen()` and reused by the other four openers,
and `afterExtglobOpenParen()` stops the escaping branch from firing on an
extglob's paren.
Parsing these bodies makes more patterns reach the repeated-extglob
machinery, so `analyzeRepeatedExtglob` now also treats a branch that
mixes a top-level `?` with a top-level `*` as risky, for every branch
count. Without it `+(?*a)` would compile to `(?:[^/][^/]*?a)+` and
backtrack catastrophically.
Regex groups, `noextglob` and malformed input are unchanged.
|
Re-measured the ReDoS interaction on a clean checkout of this branch against a freshly installed So the
That second one is a pre-existing instance of #175 that this branch happens to shut, and I had understated it at 1093 ms — that was measured at n=36, and the curve is steep enough that the exponent matters more than the constant. I want to be equally clear about the cost, because it is the same symmetry: Full suite on this branch: |
Problem
An extglob whose body — or whose first alternative — begins with
?is not parsed as an extglob at all.The
@is emitted literally and the?is escaped, so@(?ar|zip)compiles to a pattern that wants a literal@?ar. For reference,bash 3.2withshopt -s extglob:minimatch@10.2.6agrees with bash on the@(,*(,+(and?(cases above. It does not agree on the last line —minimatch('a', '!(?)')returnstruewhere bash does not match. That one matters here, so I call it out rather than claiming across-the-board agreement; see the assertion section below.picomatch@2.3.1produces byte-identical wrong regexes to 4.0.5, so this is long-standing rather than a recent regression.Root cause
Two coupled places in
lib/parse.js.1. Extglob detection. Four openers use a blanket "the character after
(is not?" guard:The guard is there so picomatch does not swallow a raw regex group —
(?:,(?=,(?!,(?<— which is a documented feature (test/regex-features.js:20pinsfoo/*(?<!d)baz). But it also rejects a plain?wildcard.The
!handler atlib/parse.js:1054already gets this right:2. Escaping inside the body.
lib/parse.js:1028escapes any?whose previous token is a paren, because(?is an invalid quantifier position in a JS regex:Correct for a plain
(, wrong for an extglob's(, which opens an alternation body where?is a wildcard. Note that*already takes the wildcard path here — which is exactly whyc!(*)zbehaves correctly onmastertoday whilec!(?)zdoes not.The fix
Two helpers next to the existing tokenizing helpers, then reused:
isExtglobOpen()is the!handler's condition plus an end-of-input guard; it now replaces the blanket guard in the?,+,@and*handlers, and the!handler collapses onto it.afterExtglobOpenParen()gates the escaping branch so it applies only to plain regex parens.@(needed a small allowance: unlike the other four it is tokenized as anattoken followed by a plain paren, so it is recognized through the token before the paren rather than by adding a flag to the paren token — the publicparse()token shape is unchanged.Deliberately preserved:
noextglob: truestill disables extglobs — the option check lives inside the helper.(?:,(?=,(?!,(?<still open regex groups.makeRe('@(?:a|b)').source === '^(?:@(?:a|b))$'andfoo/*(?<!d)bazare pinned by tests.*(,*(),a*(,@(,+(,?(,!(,*(a,*), and — thanks to thepeek(3) !== undefinedguard — for the trailing@(?,*(?,+(?and?(?as well. The one malformed pattern that does change is!(?:makeRe('!(?').sourcegoes from$^to^(?!^(?:\(\?)$).*$, i.e. from "matches nothing" to picomatch's ordinary leading-!negation of the literal(?. Both are artifacts of an unterminated extglob; I did not consider either worth preserving over the other, but it is a change.Safety: this touches the repeated-extglob ReDoS guard, deliberately
This is not a security fix and I am not claiming a vulnerability, but it does interact with the open ReDoS work (#175, mitigated in #182), so I want to be precise instead of hand-waving.
Parsing
?-leading bodies as extglobs makes more patterns reach the+(...)/*(...)repeat machinery, and some of those shapes nest an unbounded quantifier inside another one. Without further changes,+(?*a)would go from an inert literal on 4.0.5 to a backtracking bomb:+(?*a)'a'.repeat(42) + '!'^(?:\+(\?[^/]*?a))$^(?:(?=.)(?:[^/][^/]*?a)+)$^(?:\+\(\?\*a\))$analyzeRepeatedExtglobdid not catch it: the/^[*?]+$/risky-branch heuristic only runs whenbranches.length > 1, and?*ais a single branch. So this PR also adds ahasMixedWildcardscheck that runs for every branch count:A branch that mixes a top-level
?with a top-level*(?*a,*?a) is treated as risky and the extglob is literalized, exactly as the existing guard already does for*(*|X). That closes+(?*a),*(?*a),+(?*[ab])and+(?*a|b), and it is pinned by a new test intest/options.maxExtglobRecursion.jswith both a source assertion and a timing bound.Two honest notes on that:
master—+(*?a)takes 1093 ms on 4.0.5 at n=36 and is 0 ms here, and*(*?)inside a body is literalized too. That is a behaviour change to patterns unrelated to a leading?. I judged the symmetry more defensible than a rule contorted to fire only on newly-reachable bodies, but if you would rather have that split into its own PR, say so and I will narrow it.+(*a)still compiles to^(?:(?=.)(?:[^/]*?a)+)$on bothmasterand this branch, and still takes ~53 s on'a'.repeat(30) + '!'. That is untouched here and out of scope.Relationship to #191 — these two conflict
#191 ("metacharacters following a closing paren") and this PR both modify the same line in the
?handler:They are semantically complementary — #191 is about a metacharacter after the closing
), this is about a?right after the opening(— but they will conflict textually, so I named my helperafterExtglobOpenParenrather than something a character away from #191'safterExtglob. Whichever lands second, the resolution is to compose both conditions:I am happy to rebase on top of #191 if you take that one first.
One existing assertion changed
I am flagging this rather than burying it.
test/extglobs.js:Five of the six agree with bash. The
?one does not, because?— unlike.,+and@— is a glob metacharacter, exactly like the*on the second line that picomatch already handles correctly.[[ cbz == c!(?)z ]]is not a match in bash, andminimatch('cbz', 'c!(?)z')isfalse. I flipped that one assertion and left a comment.Honest caveat:
c!(?)zis still wrong afterwards, just differently. It now matches nothing, where bash matchesczandcbbz. That is the separate, pre-existing problem that a mid-pattern!()lookahead is not$-anchored (#93, #154). Net on that pattern is 0/3 correct → 1/3; I did not try to fix the anchoring.Verification
npm test: 1982 passing, up from 1977 onmaster.npm run lintclean.npm run test:ciclean.The three bug-covering assertions fail on a rebuilt
master(git show master:lib/parse.js > lib/parse.js, confirmed the added helpers are absent, then re-run); the two guard tests (regex groups still groups,noextglobopt-out) pass both before and after.I found this by differentially testing against
bash 3.2withshopt -s extglob, generating pattern/subject pairs from a small glob grammar (literals,*,?, brackets, POSIX classes, nested extglobs) over the alphabet{a,b,c}. Subjects are slash-free so bash's[[ str == pat ]]semantics line up, and I excluded[!…]/[^…]patterns because that divergence is already #187. Note thatshopt -s extglobonly affects parsing of later commands, so the oracle evaluates each pair throughevalin an already-extglob-enabled shell; I validated the batched oracle against per-pairbash -cruns before trusting it.1085 patterns × 41 subjects = 44,485 pairs:
2122 pairs newly correct, 637 pairs newly wrong across 39 distinct patterns. Every one of the 39 falls into one of four classes, none of which is new logic in this PR:
!(?…)negation, made visible by the unanchored mid-pattern lookahead (#93/#154))binding as a regex quantifier (#191)isGroupcheck atlib/parse.js:1032Concrete examples, one per class, in the form
(subject, pattern)→bash/4.0.5/ this PR:Two more from the guard class that are worth seeing plainly:
isMatch('a', '*(?|a)')andisMatch('aa', 'a*(?c|?)')were bothtrueon 4.0.5 and arefalsehere.The first class is the guard doing its job:
*(?|a)compiles to(?:[^/]|a)*, which is ambiguous and backtracks, so/^[*?]+$/flags it and the extglob degrades to a literal. That is the same treatment*(*|a)already gets onmaster. It is still a real behaviour change for those patterns and I would rather you see the number than a rounded-down one.What I could not verify
extglobbut notglobstar, and[[ == ]]does no filename expansion. Globstar, dotfile and path-separator semantics are outside what I tested.''still never matches*(?)/?(?), where bash does match. That is picomatch's blanket empty-input rule; unchanged and out of scope.+(*a)remains exponential on bothmasterand this branch.Rough microbenchmark of
makeReover 8 typical patterns × 20k iterations showed no regression.