The bound's text goes to Integer.parseInt, which accepts "+1" as 1, and the fixed width is the length of that text, so the + counts as one more digit.
- Lucene's
RegExpsyntax, used byRegexpQueryand the query parsers'/.../syntax, has an optional numeric interval<n-m>that matches the numbers from n to m written as text. It is on by default. - When both bounds have the same number of digits, every match has exactly that many characters:
<1-9>matches "1" to "9",<01-09>matches "01" to "09". When they differ, any number of leading zeros is allowed.
<+1-+9> is parsed on main: the bound text "+1" is read as 1 by Integer.parseInt but counted as two characters for the width, so the interval becomes <01-09>, compared with <1-9>. Select the still to open it at full size. Generated with AI, checked against the code.- Index the terms "5" and "05" and search with
RegexpQuery<1-9>(or type/<1-9>/into the classicQueryParser): you get "5". Bounds with the same number of digits fix the length, so<01-09>would give "05". - Write the same range with plus signs,
<+1-+9>. You expectinterval syntax error, which<-1-9>,< 1-9>and<1-9x>give, or the meaning of<1-9>. - You get no error and only "05". Each bound's text goes to
Integer.parseInt, which accepts a leading+(since Java 7), and the width is the text length: "+1" is 2 characters, so Lucene builds<01-09>. - With a
+on one bound the two lengths differ and the fixed width is dropped:<+100-999>also matches "0500" and "00500", which<100-999>rejects.<+100-+999>means "0100" to "0999".
Anyone whose regexp has a + on an interval bound, typed into a query parser or built by an application from signed number text. Interval syntax is on by default. The query silently matches a different set of terms; nothing else breaks, unsigned bounds are exhaustively correct, and nothing new can be expressed. Rare: no report since 2011.
Mechanism, terms and numbers562 words
Lucene's RegExp has an optional numeric-interval syntax <n-m>, on by default in RegexpQuery, the query parsers' /.../ syntax, Intervals.regexp and RegexCompletionQuery. Its javadoc says the bounds are "non-negative decimal integers" and that bounds with the same number of digits fix the length of the matching strings: <1-9> matches "1" to "9", <01-09> matches "01" to "09".
A bound written with a leading + is accepted without error and the + is counted as a digit. The parser passes each bound's text to Integer.parseInt, which accepts a leading + since Java 7, and takes the width from the raw text length. So <+1-+9> is built as <01-09>: a RegexpQuery or a classic QueryParser query /<+1-+9>/ returns the term "05" instead of "5". A + on one bound only makes the lengths differ, which drops the fixed width: <+100-999> also matches "0500". Every other malformed bound tried (-1, spaces, trailing text, hex, overflow) is rejected with interval syntax error. Unsigned bounds are exhaustively correct, and every + form means something an unsigned spelling can also say, so the only effect is a silently different match set for the author's own query. It has been in every release since 4.0.0-ALPHA, observable on Java 7 or later. Requiring ASCII digits before Integer.parseInt fixes it; it passes the repro, the closest suites and :lucene:core:check -x test.
- The bound texts. For
<...>with a-(:1460-1465), a-at the start, at the end or twice is rejected (:1474), thenString smin = s.substring(0, i); String smax = s.substring(i + 1);(:1476-1477). Integer.parseIntaccepts a+.int imin = Integer.parseInt(smin); int imax = Integer.parseInt(smax);(:1478-1479). Its javadoc allows "an ASCII plus sign '+' ... to indicate a positive value" (parseInt("+42", 10) returns 42); that is true since Java 7 (JDK-5017980).- The width is the text length.
if (smin.length() == smax.length()) digits = smin.length(); else digits = 0;(:1480-1482), so the+is counted. - Built as a fixed-width interval.
makeInterval(flags, imin, imax, digits)(:1488) becomesAutomata.makeDecimalInterval(min, max, digits)(:728-729), wheredigits > 0means a fixed number of digits and 0 means any number of leading zeros (Automata.java:490-501). - Every other bad bound fails. Any
NumberFormatExceptionbecomesIllegalArgumentException("interval syntax error at position ...")(:1489-1490).
| regexp | smin, smax | digits | built as | matches |
|---|---|---|---|---|
<1-9> | 1, 9 | 1 | (1, 9, 1) | 1 to 9 |
<+1-+9> | +1, +9 | 2 | (1, 9, 2) = <01-09> | 01 to 09 only |
<+1-9> | +1, 9 | 0 | (1, 9, 0) | 1 to 9 with any number of leading zeros (01, 005) |
<+100-999> | +100, 999 | 0 | (100, 999, 0) | 100 to 999 with any leading zeros (0500, 00500) |
<+100-+999> | +100, +999 | 4 | (100, 999, 4) = <0100-0999> | 0100 to 0999 only |
Over all 111,111 digit strings of length 0 to 5, <+1-+9> differs from <1-9> on 18 strings, <+1-9> on 36, and <+100-999>, <100-+999> and <+100-+999> each differ from <100-999> on 1,800. + is the only non-digit that gets through: <-1-9>, < 1-9>, <1 -9>, <1-9 >, <1-9x>, <0x1-9>, <1_0-20>, <1.0-2>, <99999999999-1>, <++1-9>, <+-1-9>, <1--9>, <+-9> and <1-+> are all rejected.
Exposure and likelihood225 words
- Who is hit. A regular expression with a numeric-interval bound written with a leading
+: typed by a user into a query parser, or built by an application such as"<" + lo + "-" + hi + ">"from text that carries a sign (a%+dformat or a user field;Integer.toStringnever adds a+). - Default reach.
RegexpQuery(Term)("By default, all regular expression features are enabled"), the classicQueryParserand the flexibleStandardQueryParser(RegExp.ALL),Intervals.regexp(default flags) andRegexCompletionQuery(Term, BitsProducer)(RegExp.ALL). The classicQueryParserpath was run:/<+1-+9>/matches "05" and not "5". - What goes wrong. No exception, and a different match set: with both bounds signed the intended values are missed and zero-padded ones match; with one bound signed any number of leading zeros is accepted.
- Limits. Deterministic, any index size, no setting. The study's bounded-exhaustive run (every
<n-m>with n, m up to 1100, leading zeros and optional+, 44.4 million parses) found 0 mismatches for unsigned bounds. Every+language equals a documented spelling (<+1-+9>=<01-09>,<+100-999>=<0100-999>; 36,905 checks), and the automaton stays at most 14 states. - Likelihood: rare. It needs a
+in a bound; then it happens every time. No report since Java 7 made it reachable in 2011.RegExpandRegexpQueryare@lucene.experimental.
One JUnit test in lucene/core, public API only: it parses <+1-+9> and passes if it is rejected with IllegalArgumentException or matches "5" like <1-9>. No index, deterministic; fails with assertions on and off.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.util.automaton;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestRegExpIntervalPlusSign extends LuceneTestCase {
public void testPlusSignOnIntervalBounds() {
// <+1-+9> must be rejected or, like <1-9>, match "5"; instead the '+' counts as a digit (<01-09>)
RegExp re;
try {
re = new RegExp("<+1-+9>");
} catch (IllegalArgumentException rejected) {
return;
}
CharacterRunAutomaton run =
new CharacterRunAutomaton(
Operations.determinize(re.toAutomaton(), Operations.DEFAULT_DETERMINIZE_WORK_LIMIT));
assertTrue(
"expected <+1-+9> to match \"5\" like <1-9>, but it does not (matches \"05\": "
+ run.run("05")
+ ")",
run.run("5"));
}
}
cd $LUCENE # an apache/lucene checkout at 645d4fe14a20 (or any commit with the same RegExp.java)
cp $REPRO/TestRegExpIntervalPlusSign.java lucene/core/src/test/org/apache/lucene/util/automaton/
./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestRegExpIntervalPlusSign -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
- Expected
<+1-+9>is rejected withIllegalArgumentException, or matches "5" like<1-9>.- Observed
- Accepted; it matches "05" but not "5" (the language of
<01-09>), on main with assertions on and off, and on 10.6.0 and 10.5.2.
More files, environment, full output347 words
The test sits in the automaton test package, as Lucene's own automaton tests do, but uses no package-private member. It accepts either fix a maintainer may choose (reject the +, or read "+1" as 1): of the three readings (rejected, <1-9>, today's <01-09>) it fails only on today's. It checks "5" only; it does not assert that "05" is rejected.
As the regression test it needs two lint edits: annotate the unused catch parameter (Lucene's ecj settings make it an error, gradle/validation/ecj-lint/ecj.javadocs.prefs:136) in the layout google-java-format requires, as QueryParserBase.java:803-805 does, and shorten the 102-character comment line to at most 100:
// <+1-+9> must be rejected or, like <1-9>, match "5"; today the '+' counts as a digit
RegExp re;
try {
re = new RegExp("<+1-+9>");
} catch (
@SuppressWarnings("unused")
IllegalArgumentException rejected) {
return;
}With those edits it fails on stock, passes with the recommended fix, and :lucene:core:check -x test passes with it in place. The one-bound width loss (<+100-999>) and <+100-+999> share the root cause and the fix; they are left out of the test.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): affected; also the 10.6.0 and 10.5.2 release jars with this test |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
TestRegExpIntervalPlusSign > testPlusSignOnIntervalBounds FAILED
java.lang.AssertionError: expected <+1-+9> to match "5" like <1-9>, but it does not (matches "05": true)
at __randomizedtesting.SeedInfo.seed([DEADBEEF:8022A4C09432B805]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.assertTrue(Assert.java:42)
at org.apache.lucene.util.automaton.TestRegExpIntervalPlusSign.testPlusSignOnIntervalBounds(TestRegExpIntervalPlusSign.java:34)
:lucene:core:test (FAILURE): 1 test, 1 failure
# the same message with -Ptests.asserts=false, and on the 10.6.0 and 10.5.2 release jars
# probe on the same main, assertions off ("matches" = which of 16 probe strings are accepted):
<1-9> ACCEPTED toString=<1-9> digits=1 matches={1,5,9}
<01-09> ACCEPTED toString=<01-09> digits=2 matches={01,05,09}
<+1-+9> ACCEPTED toString=<01-09> digits=2 matches={01,05,09}
<+1-9> ACCEPTED toString=<1-9> digits=0 matches={1,5,9,01,05,09,001,005}
<100-999> ACCEPTED toString=<100-999> digits=3 matches={100,500,999}
<+100-999> ACCEPTED toString=<100-999> digits=0 matches={100,500,0500,00500,999,0100,0999}
<+100-+999> ACCEPTED toString=<0100-0999> digits=4 matches={0500,0100,0999}
<-1-9> REJECTED IllegalArgumentException: interval syntax error at position 5
<1-9x> REJECTED IllegalArgumentException: interval syntax error at position 5
a{+2} REJECTED IllegalArgumentException: integer expected at position 2
QueryParser /<1-9>/ -> RegexpQuery f:/<1-9>/ matches 5=true 05=false
QueryParser /<+1-+9>/ -> RegexpQuery f:/<+1-+9>/ matches 5=false 05=true- The javadoc fixes the width by "the same number of digits", and
+is not a digit: read as 1, "+1" must give<1-9>; read as invalid, it must be rejected. - The parser rejects every other malformed bound tried, and upstream tests assert that truncated and empty intervals throw.
- The 2010 code predates Java 7, whose
Integer.parseIntbegan accepting+.
The full argument262 words
- The documented contract. "Numerical intervals are specified by non-negative decimal integers and include both end points, and if n and m have the same number of digits, then the conforming strings must have that length (i.e. prefixed by 0's)." (
RegExp.java:347-350). Under neither reading of "+1" is<01-09>right. - The block validates bounds. It rejects a misplaced
-(:1474) and turns everyNumberFormatExceptionintointerval syntax error(:1489-1490); the constructors document@exception IllegalArgumentException if an error occurred while parsing the regular expression(:510, :521, :533). 14 other malformed spellings are rejected. - The sibling syntax is ASCII-digits-only.
{n,m}scanspeek("0123456789")(:1272-1279);a{+2}fails withinteger expected at position 2. - Tests.
testTruncatedInterval(<1-),testTruncatedInterval2(<1) andtestEmptyInterval(<->) expectIllegalArgumentException(TestRegExpParsing.java:491-513);<1-and<1fail at the>check (:1463),<->at the bound check (:1474); no test covers a malformed bound spelling such as<1-9x>, and every interval in the tests is unsigned. - Never designed. The parse lines came with the 2010 port of dk.brics.automaton (
b679816a7001). On Java 6Integer.parseInt("+1")threw, so<+1-+9>was aninterval syntax error; JDK-5017980 (Java 7, 2011) widenedparseIntafterwards (read; no Java 6 run). - Precedent. #10410 (LUCENE-9370, type:bug): "The RegExp class is too lenient in parsing user input which can confuse or mislead users", fixed in 9.0 (its CHANGES entry is under Improvements). PR #12277 made
a{99,11}throw: "we catch an invalid usage and throw a clean exception in that case".
The devil's-advocate check162 words
The strongest case (reading-only cross-model reviews said not real on it): + is outside the documented <n-m> grammar of an @lucene.experimental class, so what it parses to is unspecified; every documented input is exhaustively correct; garbage in, garbage out.
Why it fails: the parser does not treat out-of-grammar bounds as unspecified; it rejects all the others tried, and its tests assert that truncated and empty intervals throw. The + gets through only because of a later JDK change, and the meaning it gets contradicts the digit-count rule even under the lenient reading. The root-cause reviewer graded its works-as-designed argument "strong", but still wrote: "The case is not conclusive because the interval documentation never formally defines the lexical production for n and m, and counting an accepted plus sign as a digit conflicts with the stated width rule." It is a good reason for low priority, and maintainers may file the fix as an improvement; it does not make <+1-+9> = <01-09> correct.
Recommended Bounds must be ASCII digits (reject everything else)
Require each bound to be ASCII digits 0-9 before Integer.parseInt, else throw the existing interval syntax error. Validated on main: the repro passes with assertions on and off, the automaton, regexp, query parser, intervals and regex-completion tests pass, and :lucene:core:check -x test passes. It also rejects non-ASCII digit bounds; rejecting only a leading + is the narrower alternative.
All options and trade-offs3 options · 593 words
Both reject variants were run in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF), removed afterwards. "Closest suites" means :lucene:core:test --tests 'org.apache.lucene.util.automaton.*' plus TestRegexpQuery, TestRegexpRandom, TestRegexpRandom2, TestAutomatonQuery and TestAutomatonQueryUnicode. The choice is the maintainers': reject the + (A or B), or read "+1" as 1 with width 1 (C, not run); the test accepts all three.
In RegExp.parseSimpleExp, after smin and smax are cut (:1476-1477), throw the existing NumberFormatException (turned into interval syntax error) unless both are non-empty runs of 0-9; a small isAsciiDigits helper, +15 lines. It matches the {n,m} syntax, which reads ASCII digits only, and makes "digits" in the width rule exact.
+ private static boolean isAsciiDigits(String s) {
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c < '0' || c > '9') {
+ return false;
+ }
+ }
+ return s.isEmpty() == false;
+ }
@@ parseSimpleExp, interval branch
String smin = s.substring(0, i);
String smax = s.substring(i + 1);
+ // bounds are non-negative decimal integers written with the ASCII digits 0-9 only
+ // (Integer.parseInt alone would also accept a leading '+' and non-ASCII digits)
+ if (isAsciiDigits(smin) == false || isAsciiDigits(smax) == false) {
+ throw new NumberFormatException();
+ }
int imin = Integer.parseInt(smin);Trade-offs
- It also rejects non-ASCII decimal digit bounds, which main reads as ASCII values:
<١-٩>(Arabic-Indic) and<1-9>(full-width) are accepted as<1-9>on main and rejected with this fix. That reading is defensible and not claimed as a bug, so it is a behaviour change worth a CHANGES entry; a javadoc tweak ("written with the digits 0-9") would document it. - A query that uses a
+bound today gets an exception instead of a different match set.
What the checks validated
- Stock
645d4fe14a20: the repro fails with assertions on and off. - With the fix: the repro passes with assertions on and off; the closest suites pass (261 tests including the repro, 2 skipped); queryparser
classic.*andflexible.standard.*(222 tests, 1 skipped), queriesintervals.*(117) andTestRegexCompletionQuery(5) pass;:lucene:core:check -x test(google-java-format, ecj lint, forbidden APIs, license checks) passes with the lint-ready regression test in place, which passes with the fix and fails on stock. - Probe: the five
+forms are rejected withinterval syntax error; the 14 other malformed spellings stay rejected;<1-9>,<01-09>and<100-999>keep their languages.
Four lines after :1477: if (smin.charAt(0) == '+' || smax.charAt(0) == '+') throw new NumberFormatException(); (both are non-empty there, :1474). The narrowest change: non-ASCII digit bounds keep today's reading.
// Integer.parseInt accepts a leading '+', which is not a digit of a bound
if (smin.charAt(0) == '+' || smax.charAt(0) == '+') {
throw new NumberFormatException();
}Trade-offs
- The bound check still relies on
Integer.parseInt's grammar, which is wider than the documented one.
What the checks validated
- The repro passes; the closest suites pass (261 tests including the repro, 2 skipped); the probe gives the same rejections as the recommended fix, and non-ASCII digit bounds stay accepted as
<1-9>.
Drop one leading + from each bound before the length check (still rejecting ++1), so <+1-+9> means <1-9>. It satisfies the test, but keeps accepting a spelling no documentation mentions, while {n,m} rejects a{+2}. Not run.
Downstream, an application that builds <lo-hi> range filters from signed number text (for example %+d output) would silently filter on a different range; pass digits only.
A bounded-exhaustive check parsed every interval <n-m> with n and m up to 1100, up to two leading zeros and an optional + on each bound (44.4 million parses), with the real jar and compared each result with a reference written from the javadoc. Unsigned bounds had 0 mismatches; 14,647,338 + parses got another meaning.
The method in full170 words
Method: semi-formal (the finder's label): bounded-exhaustive translation validation of the real main jar against the javadoc, not a proof.
- The unit's plan predicted the
+signal by reading theInteger.parseIntlines; the exhaustive run witnessed and measured it. - Harness
h297.IntervalTV: every<n-m>text with 0 <= n, m <= 1100, up to two leading zeros and an optional+on each bound, both orders, against a reference built from the javadoc rules; for n, m <= 120 an exact language comparison (product of the determinized Lucene automaton with the reference's minimal DFA). - 195.3 million checks in seven legs with 0 in-domain mismatches (documented input); 35 of 35 mutants of the checker were killed, and a
RegExpmutant that drops the fixed width gives 99,438 mismatches, so the check can see width errors. - An independent reproducer then wrote its own public-API test comparing each signed form with its unsigned form over all 111,111 digit strings of length 0 to 5, and ran
RegexpQueryand the classicQueryParseron an index.
No prior report found: 81 tool queries and 57 manual searches over GitHub issues and PRs, the JIRA archive, the mailing lists, downstream projects, the origin library and the web, and the changed files of all 410 open PRs (2026-10-31). Not fixed upstream.
Searches5 searches
Verdict new. The closest item is #12251 (open since 2023), which quotes this interval block as the example of correct exception handling while reporting {n,m} parsing. Its fix PR #12277 and #10410 / LUCENE-9370 are precedents for making RegExp stricter. GitHub and list searches match words, so they cannot match the literal <+1-+9>; private discussions cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene), JIRA archive, mailing lists, web | 2026-10-31 | a scripted prior-report search, 81 queries: 40 GitHub issue/PR, 3 commit, 2 downstream, 24 mailing-list (dev@, java-user@, issues@, commits@), 6 JIRA, 6 web | 505 distinct hits, 0 high overlap; every medium hit read, none about interval bounds |
| GitHub, manual | 2026-10-31 | 16 apache/lucene searches (numeric/numerical/decimal interval, leading zeros, sign, Integer.parseInt, "interval syntax error", makeDecimalInterval, INTERVAL flag); 12 on Elasticsearch, OpenSearch, Solr, Lucene.NET, dk.brics.automaton and GitHub-wide; 4 open-PR listings | closest: #12251 / #12277 (repeat parsing), #10410 (backslash leniency), #5373 (repeat messages) |
| Mailing lists, JIRA archive and web, manual | 2026-10-31 | 12 list-archive queries on dev@, java-user@ and issues@; 6 JQL queries on LUCENE, SOLR and LUCENENET; 7 web queries | 0 hits or noise; documentation and Q&A pages only |
| Open PRs and upstream branches | 2026-10-31 | changed files of all 410 open apache/lucene PRs; RegExp.java on main 2f424fd428da, branch_11x and branch_10_6 | only #14389 and #14350 touch RegExp.java (case folding); the same block on all three branches |
| Origin library dk.brics.automaton | 2026-10-31 | issues, PRs, commits, master RegExp.java | same parsing code, no report; 43fbff6420 fixed interval printing only |
Since the 2010 port of dk.brics.automaton (b679816a7001), first released in 4.0.0-ALPHA (2012); observable on Java 7 or later, which made Integer.parseInt accept +. 15 releases from 4.0.0-ALPHA to 10.6.0, and main, were run and fail the same way. Never fixed.
Versions tested10 rows
Introduced by b679816a7001 (2010-04-06, svn r931278, "LUCENE-2370: Reintegrate flex_1458 branch into trunk (revision 931101)"), the LUCENE-1606 port, with the same parseInt and width lines; no GitHub PR (SVN era).
Runtime condition. On Java 6 Integer.parseInt("+1") threw, so the same code rejected <+1-+9>; JDK-5017980 (fix version 7, resolved 2011-05-17) changed that. 4.0.0-ALPHA to 4.7.2 were built for Java 6 and are affected only on Java 7+ runtimes; 4.8.0 (Java 7 minimum) is the first release affected everywhere.
Not a regression. git log -L over the <...> branch (:1460-1491) finds five later edits, none changing what a bound means: ba56e757045 (2014, LUCENE-6046, b becomes originalString), 887fe4c83d4 (2020, LUCENE-9386, adds flags), 2d6ad2fee6d (2020, LUCENE-9570, reformatting), 2971f311a2b (2021, LUCENE-9911, the exception gets its cause) and fb8183332bc (2023, #12462, substring(i + 1)). The same block is in all 124 release tags from 4.0.0-ALPHA to 10.6.0 in the apache/lucene history (8.8.2 to 8.11.x were tagged in the older lucene-solr repository; 8.11.4 was run), upstream main 2f424fd428da and branch_10_6.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle and the standalone runner, assertions on and off) |
| 10.6.0, 10.5.2, 9.12.3 | affected | the minimal test fails (release jars, assertions on and off; 9.12.3 with assertions off in the independent fact check) |
| 10.0.0, 9.0.0, 8.11.4, 8.0.0, 7.0.0, 6.0.0, 5.0.0 | affected | the history repro fails the same way (18, 36 and 1,800 differing digit strings) |
| 4.10.4, 4.8.0 | 4.8.0: first on every runtime | the history repro fails the same way |
| 4.7.2, 4.0.0 | affected | the history repro fails the same way (Java 7+ runtimes) |
| 4.0.0-ALPHA | first affected | fails on OpenJDK 25 and OpenJDK 11 |
| 4.0.0-ALPHA to 4.7.2 on Java 6 | read, not run | Integer.parseInt("+1") threw, so <+1-+9> was an interval syntax error |
| 3.6.2, 3.0.1 | API absent | no automaton package |
| 4.0.0-ALPHA to 10.6.0, all 124 tags | read | the same parse block (tags in the apache/lucene history; 8.8.2 to 8.11.x were tagged in lucene-solr, 8.11.4 was run) |
fixes on 645d4fe14a20 | not affected | ASCII-digits and reject-+ fixes: <+1-+9> is rejected; the repro and the closest suites pass |
An independent reproducer, a devil's advocate, an adjudication and a later tie-break rated it real for the + half; cross-model reviews (OpenAI GPT-5.6, reading only) split, one real and three not real on out-of-grammar input, all agreeing on the root cause. It still reproduces on main and 10.6.0.
Checks and verdicts262 words
There is no executing cross-model review: every review by the other model family read the code only. The non-ASCII-digit half the finder also reported was excluded as defensible by the adjudication, the verifiers and the cross-model lenses.
| Check | Verdict | What it found |
|---|---|---|
| Finder (bounded-exhaustive check) | candidate | 0 mismatches for unsigned bounds; 14,647,338 + parses with another meaning; public-API JUnit fails on main, 10.5.2 and 9.12.3. |
| Independent reproducer | real (+ half) | Own test over 111,111 digit strings: 18, 36 and 1,800 differing strings; RegexpQuery and the classic QueryParser return [05] instead of [5]; passes with the fix. |
| Cross-model reviews, lenses (OpenAI GPT-5.6) | refute: not real (high); root cause: not real (medium); intended: real (medium) | Reading only. All agreed on the root cause; the root-cause lens graded its works-as-designed argument strong, yet wrote that "the case is not conclusive". |
| Devil's advocate | real, low priority | + is the only non-digit the block lets through; a{+2} is rejected; traced the leak to JDK-5017980. |
| Adjudication | confirmed, + half only, minor | Re-ran the reproducer's test with assertions on and off. |
| Credibility and noise audits | kept (downgraded to low impact) | Undocumented spelling on an experimental API; repro cut to one test. |
| Current-main check and tie-break | real | Main 645d4fe14a20, 10.6.0 and 10.5.2 fail with assertions on and off; a new reading-only GPT-5.6 refute review said not real (medium), on the contract argument and procedurally, because it could not run code. |
| This doc | still reproduces; fixes validated | Gradle on a scratch worktree of 645d4fe14a20. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 486 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,331 words
- Registry title
- RegExp numeric interval <n-m> accepts a leading '+' on a bound and counts it as a digit, so <+1-+9> matches 01..09 instead of 1..9 and <+100-999> loses its fixed width
- Area
- util / automaton
- Priority
- Low, rank 920: An undocumented '+' on a RegExp interval bound silently changes the matched language, but only with a contrived spelling.
- Impact class
- minor
- Impact note
- Hits anyone whose regular expression writes a numeric-interval bound with a leading '+', passed with the default flags to RegexpQuery or the classic QueryParser /.../ syntax [exec] (by reading also the flexible StandardQueryParser, Intervals.regexp and RegexCompletionQuery): the query silently matches a different set of strings (for <+1-+9> the two-character strings 01..09, not 1..9) instead of failing with 'interval syntax error'; documented unsigned ASCII bounds are exhaustively correct, no setting or scale is needed, and it is in every release from 4.0.0-ALPHA to 10.6.0 on Java 7 or later.
- Summary
- You run a RegexpQuery, or a classic QueryParser query /<+1-+9>/, on a field with the terms 5 and 05. The RegExp javadoc says interval bounds are non-negative decimal integers and that bounds with the same number of digits fix the length of the matching strings, so you expect either an 'interval syntax error' (as for '-1', ' 1' or an overflowing bound) or the meaning of <1-9>, which matches 5. Instead the parser accepts the '+' and counts it as a digit, so <+1-+9> matches only 01 to 09 and returns 05; <+1-9> and <+100-999> lose their fixed width and accept any number of leading zeros, and <+100-+999> means 0100 to 0999.
- Introduced
- b679816a7001ba986618f103a9a9c67ececc7ff8 (svn trunk r931278, 'LUCENE-2370: Reintegrate flex_1458 branch into trunk (revision 931101)'; the code is the dk.brics.automaton port of LUCENE-1606; SVN era, no GitHub PR; the flex-branch commits are not in the history clone) [read: git log -S 'Integer.parseInt(smin)' and git log -L in the history clone]. Latent until Java 7: Integer.parseInt accepts a leading '+' only since JDK 7 (JDK-5017980, fixVersion 7, resolved 2011-05-17); before that '+1' threw NumberFormatException, which this block turns into 'interval syntax error' [read: JDK bug record and the code's handler; no Java 6 run]. Not a Lucene regression: the block has had only two cosmetic edits since (887fe4c83d4, 2020-07-08, LUCENE-9386; fb8183332bc, 2023-08-17, #12462) [read] (2010-04-06 (Lucene commit); observable only on Java 7 or later runtimes (JDK-5017980, resolved 2011-05-17))
- First affected version
- 4.0.0-ALPHA [exec on JDK 25, plain classpath, jar SHA-1 matches Maven Central]; 4.0.0-ALPHA to 4.7.2 supported Java 6, where the input was rejected [read, not executed: no Java 6 runtime], so 4.8.0 (Java 7 minimum) is the first release affected on every supported runtime; 3.0.1 and 3.6.2 have no automaton package [exec: API absent]
- Affected versions
- every release from 4.0.0-ALPHA to 10.6.0, and main a9779ef3c193 [exec on JDK 25: the minimal test fails on the 10.6.0 jars with -ea and -da (2026-10-31); the follow-up's main() history repro fails on 4.0.0-ALPHA, 4.0.0-BETA, 4.0.0, 4.7.2 (also on JDK 11), 4.8.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails 6 of 7 on main (-ea and -da), 9.12.3, 10.0.0 and 10.5.2; the finder's JUnit fails 4 of 4 on main (-ea and -da), 9.12.3 and 10.5.2; read: the same parse block in all 123 release tags of the history clone that contain the introducing commit]. Runtime condition: Java 7 or later. Not fixed upstream: upstream main 20ced71c1e6e (2026-10-27) has the same block, and the newest RegExp.java commit is ba69bfb1ea9a (author date 2026-07-31, unrelated) [read 2026-10-28]. The origin library dk.brics.automaton has the same code
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: code reading; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-27, the prior-report record: the scripted prior-report search (30 GitHub issue and PR queries on apache/lucene, 3 commit, 2 downstream, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 6 web queries; 405 distinct hits, 0 high overlap), 23 manual GitHub searches (apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, apache/lucenenet, the origin library cs-au-dk/dk.brics.automaton and GitHub-wide), 7 manual JIRA queries (LUCENE, SOLR, LUCENENET), the changed files of all 410 open apache/lucene PRs (only #14350 and #14389 touch RegExp.java, neither the interval branch) and of every PR created 2026-10-24 to 2026-10-27; 20 hits classified, none the same bug). Refreshed 2026-10-28 for this row: upstream main is still 20ced71c1e6e (author date 2026-10-27); the newest RegExp.java commit is ba69bfb1ea9a (author and committer date 2026-07-31, unrelated); none of the 14 PRs created since 2026-10-26 (#16907-#16925) touches RegExp, RegexpQuery or a query parser (file lists checked); the only item updated since 2026-10-27 that mentions RegExp is issue #16918 (a deprecation list naming RegExp.ASCII_CASE_INSENSITIVE, unrelated); the origin library has had no commit or issue since 2026-10-24. Not fixed upstream. Closest items, not reports: #12251 (open since 2023-04-28, drzhonghao: quotes this parseSimpleExp block but reports NumberFormatException handling in parseRepeatExp {n,m}) and its fix PR #12277 (merged 2023-11-02, parseRepeatExp only, a{99,11} now rejected); #10410 (LUCENE-9370, closed 2020: RegExp leniency on '\' turned into an error, a precedent); the origin library's 2026-10-24 RegExp.toString fix for numerical intervals (commit 43fbff6420, printing only; its bound parsing is unchanged and has the same '+' defect, unreported there).
- Lucene commit tested
- main
645d4fe14a20(gradle in a scratch worktree, assertions on and off; the standalone runner; probes on the jars); the 10.6.0 and 10.5.2 release jars with the minimal test, assertions on and off; the study's runs on maina9779ef3c193(the automaton package,RegexpQueryand the queryparser module are identical) and the history repro on 4.0.0-ALPHA, 4.0.0, 4.7.2, 4.8.0, 4.10.4, 5.0.0, 6.0.0, 7.0.0, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0; both reject fixes in a scratch worktree of645d4fe14a20. Upstream main2f424fd428da(2026-10-30) has the same parse lines.
- Finding record
lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:271-272 (at a9779ef3c193) (grammar row <n-m>, 'numerical interval', [OPTIONAL]), :341-343 (reserved characters must be escaped), :347-350 (class javadoc: 'Numerical intervals are specified by non-negative decimal integers and include both end points, and if n and m have the same number of digits, then the conforming strings must have that length (i.e. prefixed by 0's).'), :352 (@lucene.experimental), :728-729 (REGEXP_INTERVAL -> Automata.makeDecimalInterval(min, max, digits)), :1270-1279 ({n,m} reads ASCII digits only, peek("0123456789")), :1462-1491 (interval branch: rejects a '-' at position 0, at the end or a second '-'; Integer.parseInt(smin) and (smax) at :1478-1479; digits = smin.length() when both raw lengths match, :1481; NumberFormatException rethrown as 'interval syntax error' at :1490)lucene/core/src/java/org/apache/lucene/search/RegexpQuery.java:49-56 (at a9779ef3c193) ('By default, all regular expression features are enabled'; RegexpQuery(Term) uses RegExp.ALL) and lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:511-513 (newRegexpQuery uses RegExp.ALL)Registration check prior-art refresh 2026-10-28 (read-only GitHub API: apache/lucene commits for RegExp.java and main HEAD, PR search created:>=2026-10-26 with the changed files of all 14 PRs, issue and PR search updated:>=2026-10-27 RegExp, issue #16918, cs-au-dk/dk.brics.automaton commits and issues)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:271-273, (at 645d4fe14a20) 347-350, 408, 411, 513, 728-729, 1460-1465, 1474-1490lucene/core/src/java/org/apache/lucene/util/automaton/Automata.java:490-501 (at 645d4fe14a20)OpenJDK 25.0.3 lib/src.zip java.base/java/lang/Integer.java (parseInt javadoc: plus sign, parseInt("+42", 10) returns 42)
- Who is affected
lucene/core/src/java/org/apache/lucene/search/RegexpQuery.java:40, (at 645d4fe14a20) 50, 54-56lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:511-513 (at 645d4fe14a20)lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/builders/RegexpQueryNodeBuilder.java:45-50 (at 645d4fe14a20)lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:237-238 (at 645d4fe14a20)lucene/suggest/src/java/org/apache/lucene/search/suggest/document/RegexCompletionQuery.java:60-61 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:352 (at 645d4fe14a20) (@lucene.experimental)
- Minimal repro
Lucene main 645d4fe14a20 gradle/validation/ecj-lint/ecj.javadocs.prefs:136; lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:803-805
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:347-350, (at 645d4fe14a20) 352, 510, 521, 533, 1272-1279, 1474, 1489-1490lucene/core/src/test/org/apache/lucene/util/automaton/TestRegExpParsing.java:465-513; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestRegexpQuery.java:98-99
- History
a Lucene clone releases/lucene/10.6.0 RegExp.java:1501-1504 (same lines)
- TestRegExpIntervalPlusSign.java (1 test):
repro/LFV-813/src/org/apache/lucene/util/automaton/TestRegExpIntervalPlusSign.java(40 lines)
Drawn from the code at 645d4fe14a20 and the executed runs.
assets/LFV-813/bug.png(486 KB)
