- A hunspell dictionary is two files: the
.afffile with rules, and the.dicfile with one word per line, optionally followed by flags (work/A) and, after a tab, morphological fields such aspo:noun(part of speech). - To make the files smaller, the
.affcan number repeated values in alias tables:AFfor flag sets andAMfor morphological fields.AM 2declares two entries; the next two lines, for exampleAM po:nounandAM po:verb, are aliases 1 and 2. - A
.dicentry then writes only the number:work/A<TAB>2means flagAand the morphological field of alias 2. Lucene's own test dictionarymorphaliasworks this way (AM 4, entries using aliases 1 to 4).
AM table with one entry and a .dic entry asking for alias 2: the lookup reads slot 1 of a 1-slot array and throws, while the same mistake in the AF table gets a named error. Select the still to open it at full size. Generated with AI, checked against the code.- Load a hunspell dictionary whose
.affhas a one-entry morphological alias table,AM 1thenAM po:noun, and whose.dichas the entryfoo<TAB>2: the wordfoowith alias 2, which the table does not define. - You expect a descriptive error, as for the same mistake in the flag-alias table (
AF 1,AF Aand the entryfoo/2):IllegalArgumentException: Bad flag alias number:2. C Hunspell's source range-checks the index and keeps the word without morphological data (itsbad morph. alias indexwarning prints only in builds with warnings switched on). - Instead Lucene looks up
morphAliases[2 - 1]in a 1-slot array with no range check:ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1, and the error does not say which alias or input line is wrong. - Alias 0 gives
Index -1 out of bounds for length 1;AM 2with only oneAMline givesNullPointerException ... because "morphData" is null.HunspellStemFilterFactorywraps onlyParseException, so its users get the raw exception too. Every release since 4.10.0 (2014) has it.
Applications that load hunspell dictionaries with AM morphological aliases, through any Dictionary constructor or HunspellStemFilterFactory, when a .dic entry points at an undefined alias: a hand edit, an AM table with fewer lines than its count, or a .dic paired with another version's .aff. Rare; the load fails either way.
Mechanism, terms and numbers486 words
Hunspell affix files can number their morphological descriptions in an AM table ("alias compression"), and .dic entries then carry only the number. When an entry uses a number the table does not define, Dictionary.splitMorphData reads morphAliases[alias - 1] with no range check and catches only NumberFormatException. So AM 1 / AM po:noun plus the entry foo<TAB>2 makes every Dictionary constructor throw ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1; alias 0 gives Index -1, and an AM table with fewer lines than its count gives a NullPointerException. The flag-alias lookup a few lines above turns the same out-of-range lookup into IllegalArgumentException: Bad flag alias number:N, and has since 2012 (LUCENE-4079); C Hunspell's source range-checks the index. The unchecked lookup came with AM support itself (LUCENE-5778, 4.10.0) and is in every release since, including 10.6.0 and main. The malformed dictionary is refused either way: there are no wrong results, only an unhelpful error. A one-line range check mirroring the AF guard fixes it.
AMtable (morphological aliases): a headerAM <count>and then oneAM <fields>line per alias, numbered from 1. hunspell(5): "Hunspell can also substitute morphological data with ordinal numbers in affix rules (alias compression)".AFtable (flag aliases): the same idea for flag sets;foo/2means the flags ofAFalias 2.morphAliasCount: how manyAMentries the.affactually defined.
parseMorphAlias(Dictionary.java:1295-1304at645d4fe14a20) allocatesmorphAliases = new String[count]from the declared count and fills one slot perAMline, so slots beyond the lines actually given staynull.splitMorphData(:1306-1315) parses a numeric morphological field and doesmorphData = morphAliases[alias - 1](:1311) inside atrythat catches onlyNumberFormatException(meant for non-numeric, literal fields). There is no check ofaliasagainst 1 andmorphAliasCount.- An alias above the declared count, or 0 or below, throws
ArrayIndexOutOfBoundsException; an alias in a declared but undefined slot yieldsnulland aNullPointerExceptionatmorphData.isBlank()(:1315). - Both loading passes reach it (
mergeDictionaries:1030while probing for morphological data, andreadMorphFields:1142/:1207for every entry with morphological fields while the word table is built), with either sorting strategy, and the exception leaves the constructor. - The flag-alias sibling
getAliasValue(:1287-1293) catches theIndexOutOfBoundsExceptionand throwsIllegalArgumentException("Bad flag alias number:" + id, ex).
.aff SET UTF-8, AM 1, AM po:noun; .dic 1, foo<TAB>2: ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1 at Dictionary.splitMorphData(Dictionary.java:1311), from mergeDictionaries(:1030) and the constructor (:268). foo<TAB>0: Index -1 out of bounds for length 1; foo<TAB>-1: Index -2. AM 2 with only AM po:noun, entry foo<TAB>2: NullPointerException: Cannot invoke "String.isBlank()" because "morphData" is null. Control: AF 1, AF A, entry foo/2: IllegalArgumentException: Bad flag alias number:2.
Exposure and likelihood262 words
- Who is hit. Users of the opt-in hunspell analysis component whose dictionaries use
AMaliases, throughnew Dictionary(...)(all constructors, both sorting strategies) orHunspellStemFilterFactory(SPI namehunspellStem, for example inCustomAnalyzer). - What it takes. One
.dicentry whose morphological field is a number theAMtable does not define. No upstream test dictionary (Lucene'smorphalias; C Hunspell'salias,alias2andalias3tests) or report shows one, and public dictionaries were not surveyed, so it is rated rare. - What happens. One bad word entry fails the whole load, with an exception that does not say which alias or input line is wrong: "Index 1 out of bounds for length 1" means "alias 2 is not in your
AMtable".HunspellStemFilterFactory.informwraps onlyParseExceptioninto itsUnable to load hunspell data! [dictionary=...,affix=...]IOException(HunspellStemFilterFactory.java:115-118), so factory users do not even get the file names. - What does not happen. No wrong stems, no silent acceptance, no hang, nothing written. Correct dictionaries, dictionaries without an
AMtable, and dictionaries whoseAMheader has noAMlines (the lookup is skipped whilemorphAliasCountis 0,Dictionary.java:1308) do not hit it. - Related, separate fix sites (not part of this bug). An
AFtable that declares more aliases than it defines givesNullPointerException ... "rawFlags" is null;AF/AMtables with more lines than their count, or a missing or non-numeric count, throw raw exceptions while the.affis parsed. With the offline sorting strategy this exception is also one trigger of LFV-1006 (temp output not released on failure).
One JUnit test on the public API: a one-entry AM table and a one-word .dic loaded through new Dictionary(aff, List.of(dic), false, SortingStrategy.inMemory()). It accepts a successful load, a ParseException or an IllegalArgumentException; it turns an IndexOutOfBoundsException into a clear failure, and any other unexpected exception also fails it. Deterministic, single thread, about 0.3 s.
/*
* 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.analysis.hunspell;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.List;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestDictionaryUndefinedMorphAlias extends LuceneTestCase {
private static InputStream stream(String s) {
return new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8));
}
public void testMorphAliasBeyondAmTable() throws Exception {
InputStream aff = stream("SET UTF-8\nAM 1\nAM po:noun\n");
InputStream dic = stream("1\nfoo\t2\n");
try {
new Dictionary(aff, List.of(dic), false, SortingStrategy.inMemory());
} catch (ParseException | IllegalArgumentException descriptive) {
// loading foo or a descriptive error (as AF gives "Bad flag alias number") is fine
} catch (IndexOutOfBoundsException e) {
throw new AssertionError(
"expected a descriptive error for undefined AM alias 2 (the table has 1 entry); actual: "
+ e,
e);
}
}
}
cp TestDictionaryUndefinedMorphAlias.java $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests org.apache.lucene.analysis.hunspell.TestDictionaryUndefinedMorphAlias -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
- Expected
- The load fails with a descriptive error such as
Bad flag alias number:2for theAFtable, orfooloads (C Hunspell). - Observed
ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1fromDictionary.splitMorphData; the same with assertions off.
More files, environment, full output279 words
The oracle does not pin the fix's exception type: loading foo (what C Hunspell's source does), a ParseException (what the constructors document) or an IllegalArgumentException (what the AF table gives) all pass. It needs SortingStrategy, so it compiles on 9.9.0 and later; older releases were checked with a release-agnostic probe that calls the public constructor of each version.
As the regression test: the same test, for example as a new method in TestDictionary, with the unused catch parameter descriptive renamed to _ (Lucene's ecjLintTest rejects unused exception parameters; checked with gradle). With the recommended fix it can be tightened to expectThrows(IllegalArgumentException.class, ...). It pins the beyond-the-table case only; that the fix also covers alias 0 and the NullPointerException case was shown by probes with the fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 10.6.0 and 9.9.0; the same lookup fails on every release from 4.10.0; 4.9.x not affected |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp TestDictionaryUndefinedMorphAlias.java $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests org.apache.lucene.analysis.hunspell.TestDictionaryUndefinedMorphAlias -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
# gradle, main 645d4fe14a20, seed DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestDictionaryUndefinedMorphAlias > testMorphAliasBeyondAmTable FAILED
java.lang.AssertionError: expected a descriptive error for undefined AM alias 2 (the table has 1 entry); actual: java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
at __randomizedtesting.SeedInfo.seed([DEADBEEF:36D1E7B404813866]:0)
at org.apache.lucene.analysis.hunspell.TestDictionaryUndefinedMorphAlias.testMorphAliasBeyondAmTable(TestDictionaryUndefinedMorphAlias.java:40)
Caused by:
java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
at org.apache.lucene.analysis.hunspell.Dictionary.splitMorphData(Dictionary.java:1311)
at org.apache.lucene.analysis.hunspell.Dictionary.mergeDictionaries(Dictionary.java:1030)
at org.apache.lucene.analysis.hunspell.Dictionary.<init>(Dictionary.java:268)
at org.apache.lucene.analysis.hunspell.TestDictionaryUndefinedMorphAlias.testMorphAliasBeyondAmTable(TestDictionaryUndefinedMorphAlias.java:36)
# with the recommended range check: the test passes (assertions on and off)
# release jars 10.6.0 (assertions on and off) and 9.9.0 (off): same failure, lookup at Dictionary.java:1323 and :1278- The same lookup for flag aliases has thrown
Bad flag alias numbersince 2012 (LUCENE-4079);AMsupport came in 2014 without the guard. - The constructors document
ParseExceptionfor malformed content; nothing presents an index exception as the signal. - In 2012 the hunspell maintainer asked for such errors to be fixed "at their source, to provide targeted Exceptions" (LUCENE-3976).
The full argument246 words
- Sibling guard.
getAliasValue(Dictionary.java:1287-1293) wraps the out-of-rangeAFlookup intoIllegalArgumentException("Bad flag alias number:" + id, ex); it came withAFsupport in LUCENE-4079 (b4fe59532ee, 2012-05-30) and LUCENE-5468 carried it into the current class. LUCENE-5778 (532d9f246b8, 2014-06-21) addedAMwithmorphAliases[alias-1]and aNumberFormatExceptioncatch commented// fine; its issue never discusses undefined aliases. - Contract.
@throws ParseException Can be thrown if the content of the files does not meet expected formats(:187,:204,:224). No javadoc, test,MIGRATE.mdorCHANGES.txtentry mentions an index exception for malformed content. - Maintainer intent. LUCENE-3976 (#5049), Chris Male, 2012-06-01: "I think we should explore what those possible errors are and fix them at their source, to provide targeted Exceptions. If there is a problem parsing, then we should thrown a ParseException with the line number causing the problem", then "We will tackle error messages on a per-error basis". Later work went the same way: #12468 (stricter
.affchecks withParseException) and #16919 (merged 2026-10-28: it replaced anAssertionErrorfor a malformed affix condition with anIllegalStateExceptionand stopped catchingThrowableinAffixCondition). - Tests.
TestDictionary.testInvalidDataexpectsParseExceptionfor malformed affix rules; no hunspell test uses an undefinedAMalias, and none expects an index exception. - Reference implementation. C Hunspell's
HashMgr::get_aliasmchecksindex > 0 && index <= aliasm.size()and otherwise returns null; itserror: bad morph. alias index: %dwarning prints only in builds withHUNSPELL_WARNING_ON.
The devil's-advocate check166 words
The strongest case for "intended" (an intended-behaviour checker, and a read-only reviewer from another model family, OpenAI GPT-5.6, which leaned "not real"): the input is a malformed, trusted configuration file and is refused loudly either way, with no wrong results; "can be thrown" is permissive, not a promise that every malformed file gives ParseException; the parser already lets several unchecked exceptions escape for nearby malformed content (the AM/AF header parsers); and upstream files such changes as improvements (LUCENE-3976 was an enhancement, #12468 is listed under Improvements).
Why it fails: it limits severity, not validity. Nothing presents an index exception as intended; the same mistake one table over is deliberately turned into a named error; and the maintainer's stated policy was to fix such errors at their source with targeted exceptions. The other raw exceptions are separate, equally unpolished sites, not evidence of a design. Hence "wrong error" at low priority: the case is for a descriptive error, not a crash on valid input.
Recommended A. Range-check the alias and throw a named error (mirrors getAliasValue)
In splitMorphData, check the alias against 1 and the number of AM entries defined, and throw IllegalArgumentException("Bad morphological alias number:" + alias), as the AF guard does. This also covers alias 0 and the NullPointerException case. With it the repro passes with assertions on and off; the hunspell package runs 159 tests with 0 failures.
int alias = Integer.parseInt(morphData.trim());
+ if (alias < 1 || alias > morphAliasCount) {
+ throw new IllegalArgumentException("Bad morphological alias number:" + alias);
+ }
morphData = morphAliases[alias - 1];All options and trade-offs3 options · 528 words
Two options were run through gradle in a scratch worktree of 645d4fe14a20; A is recommended.
A. Range-check the alias and throw a named error (mirrors getAliasValue) (validated by the checks) (recommended)
In Dictionary.splitMorphData (Dictionary.java:1310-1311).
int alias = Integer.parseInt(morphData.trim());
+ if (alias < 1 || alias > morphAliasCount) {
+ throw new IllegalArgumentException("Bad morphological alias number:" + alias);
+ }
morphData = morphAliases[alias - 1];Trade-offs
- The load still fails, now with the same exception type and wording as the
AFcase. - Comparing with
morphAliasCount(entries defined), not the array length, also covers alias 0, negative aliases and theNullPointerExceptioncase. - No line number:
splitMorphDatahas no reader; aParseExceptionwith a line would need the callers to track lines and rethrow. IllegalArgumentExceptionis not aParseException, soHunspellStemFilterFactorystill adds no file names, as forAFtoday. No signature or file-format change, but the exception callers see for this malformed input changes; one comparison per numeric morphological field at load time.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20: without the fix the repro fails with assertions on and off; with it, it passes with assertions on and off. - Closest suites: every test class of the hunspell package (
TestDictionary,TestMorphAlias,TestMorphData,TestMorph,TestHunspellStemFilterFactory,TestSpellChecking,TestStemmer, ...): 47 classes with the repro, 159 tests, 0 failures, 13 skipped (three classes that need external dictionaries or corpora, or are@Ignored). ./gradlew :lucene:analysis:common:check -x testpasses with the fix and the test in place (google-java-format, ecj lint, forbidden APIs, checksum checks) once the test's unused catch parameterdescriptiveis renamed to_; with the original nameecjLintTestrejects it. The renamed test still fails on stock and passes with the fix. The full test suite was not run.- Earlier, shadowed into the stock main jars: the finder's 5-test JUnit passes 5/5 with 79 upstream hunspell tests, the reproducer's 4-test JUnit 4/4, and the minimal test with assertions on and off.
B. Tolerate it like C Hunspell: an undefined alias means no morphological data (validated by the checks)
The same check, but return Collections.emptyList(); instead of the throw.
int alias = Integer.parseInt(morphData.trim());
+ if (alias < 1 || alias > morphAliasCount) {
+ // like C Hunspell, which warns "bad morph. alias index" and keeps the word
+ return Collections.emptyList();
+ }
morphData = morphAliases[alias - 1];Trade-offs
The dictionary loads and foo has no morphological data, as in C Hunspell's source. But the mistake goes unnoticed: Dictionary has no warning channel, and C Hunspell's own warning prints only in builds with HUNSPELL_WARNING_ON; and it departs from Lucene's choice for the same mistake in AF, which fails.
What the checks validated
Gradle in the same scratch worktree: the repro passes with assertions on, and the hunspell package runs without failures (47 classes, 159 tests, 0 failures, the same 13 skips). Assertions off and the static checks were not run for B.
The getAliasValue pattern.
Trade-offs
Fixes the ArrayIndexOutOfBoundsException cases but not the NullPointerException one: declared but undefined slots are inside the array and hold null. Not tried.
Products that load user-supplied hunspell dictionaries and catch only ParseException or IOException around the load, as HunspellStemFilterFactory does, will see this ArrayIndexOutOfBoundsException escape instead.
Non-formal: while listing which malformed-content exceptions can interrupt hunspell dictionary loading (for a resource-release model of a sibling bug), an AI tool reading splitMorphData saw the unchecked lookup next to the guarded AF one. A probe of seven alias-table cases and a public-API JUnit test then reproduced it.
The method in full117 words
Method: non-formal (code reading, then executed tests). On 2026-10-29 the hunt unit for analysis loaders was modelling, in TLA+, whether the hunspell loader releases its temporary sort output when loading fails (the sibling LFV-1006). To list the content-driven exceptions that can make the dictionary-merge step throw, it read the parsing code and noticed that splitMorphData reads morphAliases[alias - 1] unchecked while getAliasValue guards the same lookup. The TLA+ model has no alias logic; the defect was read off the code. A probe (seven alias-table cases, including the NullPointerException variant and the AF neighbours) and a 5-test public-API JUnit confirmed it; independent checkers then reproduced it with their own tests, and it was cut to the one-test repro.
No prior report found in GitHub issues, PRs and commits (incl. the JIRA archive), all 415 open PRs, the Lucene mailing lists, downstream trackers or the web (2026-10-29); LUCENE-3976 (2012) is related context only. Not fixed upstream as of 2026-11-01.
Searches4 searches
Closest items, none a report of this bug: #6840 (LUCENE-5778, the introducing feature); #5151 (LUCENE-4079, added AF aliases and the Bad flag alias number guard); #5049 (LUCENE-3976, 2012, Won't Fix: a generic request for better hunspell error messages, triggered at another site two years before AM aliases existed, closed so errors get fixed one at a time); SOLR-2934 (a 2012 comment that such exceptions on some dictionaries are hard to debug, old class, other site); #10772 and #6570 (alias load errors on valid input, fixed); #12468, #14239, #10732 (validation of other tables); #16919 (AffixCondition only); open PR #16422 (directive parsing, alias code unchanged). Related finding: LFV-345 (truncated REP/MAP/ICONV tables give NullPointerException), same class, different fix site. Limits: GitHub search does not index code or diffs; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues and PRs (incl. the migrated JIRA issues) and commits | 2026-10-29 | two scripted prior-report searches (36 issue/PR and 6 commit queries) plus 29 manual issue/PR and 3 commit queries: aliases, morphological, AM, NPE/AIOOBE, ParseException, malformed, "Bad flag", "error message", HunspellStemFilterFactory | no report; splitMorphData 0 hits |
| Open PRs | 2026-10-29 | changed files of all 415 open PRs and of every PR updated since 2026-10-25 | only #16422 touches Dictionary.java, alias code unchanged |
| JIRA archive, mailing lists, downstream trackers, web | 2026-10-29 | 8 scripted and 6 manual JIRA queries (LUCENE, SOLR); 40 queries over dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch, Solr and Lucene.NET; 12 scripted and 16 manual web queries | context only (LUCENE-3976, SOLR-2934) |
| Upstream main | 2026-11-01 | file contents of Dictionary.java at 2f424fd428da (2026-10-30) and its commit list | still morphData = morphAliases[alias - 1];; newest change #16313 (2026-07-21, refactor) |
Introduced with AM support itself in 4.10.0 by LUCENE-5778 (532d9f246b8, 2014-06-21). Every release from 4.10.0 to 10.6.0 and main fails; 4.9.0 and 4.9.1, which do not parse AM, are not affected.
Versions tested6 rows
Introduced by LUCENE-5778 "Support hunspell morphological description fields" (#6840, by Robert Muir, pre-GitHub, no PR): trunk commit 532d9f246b8 and branch_4x backport b937174858c, both 2014-06-21; git tag --contains b937174858c starts at 4.10.0. Later commits to these lines (a comment typo, a reformat, the rename parseStemException -> splitMorphData in LUCENE-9734, catch-variable renames) change no behaviour. The NullPointerException message follows the code shape: String.indexOf (4.10.0-8.8.2), String.trim (8.9.0-8.11.4), String.isBlank (9.0.0-main).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (standalone and gradle); passes with fix A or B |
| 10.6.0 | affected | minimal repro fails with assertions on and off (release jars) |
branch_10_6 4ae946e5a9b | affected | release-agnostic probe fails |
| 9.9.0, 9.12.3, 10.5.2 | affected | the finder's 5-test JUnit fails 3 of 5; the minimal repro fails on 9.9.0 |
| 4.10.0 to 10.5.2 (112 releases) | first affected 4.10.0 | release-agnostic probe fails on every release (ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1) |
| 4.9.0, 4.9.1 | no AM support | the same input loads; the AF control already gives Bad flag alias number:2 |
Confirmed with high confidence: the finder, an independent reproducer, an intended-behaviour devil's advocate and an adjudication all reproduced it. A reviewer from another model family, unable to run code, leaned "not real" on the contract but agreed with the root cause.
Checks and verdicts251 words
Everyone agrees on the root cause and that it is a wrong error only: the dictionary is refused either way.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | 5-test JUnit fails 3 of 5 on main with assertions on and off; both controls pass; the probe adds the NullPointerException case; the fix passes 5/5 and 79 upstream hunspell tests. |
| cross-model review, refute lens (OpenAI GPT-5.6, read-only) | not real, medium | Could not run code; argued malformed data is outside the contract and the javadoc promises no ParseException. Agreed on the root cause and called the missing guard accidental. A separate re-run: 3 of 5 fail. |
| independent reproducer | real | Its own JUnit, written from the claim, fails 2 of 4 on main (assertions on and off), 10.5.2 and 9.12.3; controls pass; 4/4 with the fix. |
| cross-model review, intended lens (OpenAI GPT-5.6, read-only) | real, medium | The works-as-designed case is moderate but does not hold: the AF sibling wraps the same condition. |
| intended-behaviour devil's advocate | real, minor | Built the strongest intended case and rejected it (LUCENE-4079 guard, LUCENE-3976 maintainer statement); main and 10.5.2 fail 3 of 5. |
| adjudication | confirmed, high | Re-ran the reproducer's JUnit with assertions on and off: 2 of 4 fail in each. It classed it as a missing range check with an unhelpful error, low priority. |
| Registration check and credibility audit | confirmed | Re-runs on main fail 3 of 5 with assertions on and off; 5/5 with the fix shadowed. |
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 475 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,402 words
- Registry title
- Hunspell Dictionary throws a raw ArrayIndexOutOfBoundsException (or NullPointerException) instead of a descriptive error when a .dic entry uses an AM morphological alias number that the .aff file does not define, although the same mistake in the AF flag-alias table gets 'Bad flag alias number'
- Area
- analysis-common / hunspell Dictionary parsing (AM morphological aliases)
- Priority
- Low, rank 1062: A wrong-error case (internal AIOOBE/NPE instead of a parse error) in the opt-in Hunspell module that needs a malformed .dic/.aff pair.
- Impact class
- wrong error
- Impact note
- Hits applications that load hunspell dictionaries with AM morphological aliases (compressed dictionaries) through any Dictionary constructor or HunspellStemFilterFactory (CustomAnalyzer 'hunspellStem'), and only when the .dic refers to an alias number that the .aff's AM table does not define (a hand edit, an AM table with fewer lines than its count, or a .dic paired with a different .aff); the malformed dictionary is refused either way, but with an internal ArrayIndexOutOfBoundsException or NullPointerException that names neither the alias nor the input line (the factory wraps only ParseException, so its users get the raw exception too); there are no wrong results and nothing is corrupted.
- Summary
- You load a hunspell dictionary whose affix file has an AM table, for example 'AM 1' and 'AM po:noun', and whose .dic has an entry 'foo<TAB>2' that points at an alias the table does not define (or alias 0, or 'AM 2' with only one AM line). You expect a descriptive error, as for the same mistake in the flag-alias (AF) table, which gives IllegalArgumentException 'Bad flag alias number:2' (C Hunspell's source, the reference implementation, range-checks the index and keeps the word without morphological data; its 'bad morph. alias index' warning prints only in builds with HUNSPELL_WARNING_ON). Instead every Dictionary constructor fails with java.lang.ArrayIndexOutOfBoundsException 'Index 1 out of bounds for length 1' (or NullPointerException 'Cannot invoke "String.isBlank()" because "morphData" is null') from Dictionary.splitMorphData, which reads morphAliases[alias - 1] with no range check, and has done so since AM aliases were added in 4.10.0 (LUCENE-5778).
- Introduced
- 532d9f246b829d69b548b5716c591b925ed47ca3 (trunk; LUCENE-5778 'Support hunspell morphological description fields', migrated as apache/lucene#6840, by Robert Muir, svn r1604354, no PR: pre-GitHub), backported to branch_4x as b937174858c7978da798e86089b36c50cdb820f0 (r1604355). It added parseMorphAlias (array sized by the declared AM count) and the alias lookup 'morphData = morphAliases[alias-1]' inside a try that catches only NumberFormatException; it did not copy the range guard that the AF lookup (getAliasValue) has had since LUCENE-4079 (b4fe59532ee, 2012-05-30). No later commit added a range or null check: 2189b7a761d (LUCENE-6134, comment), 8ef6a0da568 (LUCENE-9570, reformat), 1852d7ad5ae (LUCENE-9734 / #2308, renamed parseStemException to splitMorphData, lookup copied unchanged), 2971f311a2b (#70) and ad24abfb0df (#15109) change no behaviour [read: git log -S 'morphAliases[', -G 'morphAliases\[alias', -L on splitMorphData and parseMorphAlias, blame of Dictionary.java:1306-1314 on 645d4fe] [exec: release probe passes on 4.9.0 and 4.9.1, which have no AM support, and fails from 4.10.0]. Bisection is at release granularity; the introducing commit is the feature commit itself. (2014-06-21 (532d9f246b8 on trunk 13:15:57 UTC and b937174858c on branch_4x 13:19:02 UTC; LUCENE-5778 resolved the same day))
- First affected version
- 4.10.0 [exec: the release probe fails on 4.10.0 with -ea and -da and passes on 4.9.0 and 4.9.1, which do not parse AM; git tag --contains b937174858c starts at 4.10.0]
- Affected versions
- [exec] every release on Maven Central from 4.10.0 to 10.5.2 (112 releases; release-agnostic main() probe MorphAliasHistoryProbe with -ea, also -da on 4.10.0, 10.5.2 and main), 10.6.0 (4ae946e5a9b: the probe on the branch_10_6 jars; the minimal repro on the SHA-1-checked 10.6.0 release jars, -ea and -da, 2026-11-01) and main 645d4fe14a20 (-ea and -da: the finder, the independent reproducer, the intended-behaviour check, the adjudication and separate re-runs). The finder's 5-test JUnit (needs SortingStrategy, so 9.9.0 or later) fails 3 of 5 on 9.9.0, 9.12.3, 10.5.2 and main; the independent reproduction's JUnit fails 2 of 4 on main, 10.5.2 and 9.12.3; the finder's main() variant fails on 8.11.2. The NullPointerException variant (AM declares more entries than it defines) fails on every probed version too; only its message changes with the code shape (String.indexOf 4.10.0-8.8.2, String.trim 8.9.0-8.11.4, String.isBlank 9.0.0-main). Not affected: 4.9.0 and 4.9.1 [exec] and older [read], where AM aliases did not exist. Not new in 10.6. Not fixed upstream: main head is still 645d4fe and the newest commit to Dictionary.java on main is #16313 (author date 2026-07-21, a refactor) (checked 2026-10-29). All runs JDK 25 on aarch64 Linux.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code and voted “not real” while agreeing with the root cause (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
- non-formal: code reading
- Upstream and prior reports
- none found (searched 2026-10-29, read-only: two scripted prior-report searches with 106 queries (36 GitHub issue/PR, 6 commit, 4 downstream, 40 mailing-list over dev@, java-user@, issues@ and commits@, 8 JIRA, 12 web), 29 manual GitHub issue/PR and 3 commit queries over apache/lucene, downstream searches of Elasticsearch, OpenSearch, Solr and Lucene.NET, manual JQL over the LUCENE and SOLR JIRA projects, the changed files of all 415 open apache/lucene PRs and of the PRs updated since 2026-10-25; the finder's 51-query run and the independent reproduction's, the intended-behaviour check's and the adjudication's searches also found nothing; refresh 2026-10-29: main head still 645d4fe, the only hunspell PR created since 2026-10-27 is #16919 (AffixCondition only), 'hunspell alias' returns only #10772, #5151, #6570 and #6840, 'splitMorphData' 0 hits). Related, not reports of this bug: #6840 (LUCENE-5778, 2014: the introducing feature; undefined aliases never discussed), #5151 (LUCENE-4079, 2012: added AF aliases and the 'Bad flag alias number' guard), #5049 (LUCENE-3976, 2012, Won't Fix: a generic request for better hunspell error messages, triggered by an ArrayIndexOutOfBoundsException at another site and closed so that such errors get fixed one at a time at their source; it predates AM aliases), SOLR-2934 (a 2012 comment that ArrayIndexOutOfBoundsExceptions on some dictionaries are hard to debug; old HunspellDictionary, other site), #10772 (LUCENE-9733) and #6570 (LUCENE-5507: fixed AF-alias load errors on valid input), #12468, #14239, #14079 and #10732 (LUCENE-9693: validation of other tables and of flags), hunspell/hunspell#540 (another project: an unparseable AF alias); the only open PR on Dictionary.java, #16422, changes affix directive parsing only.
- Lucene commit tested
- main
645d4fe14a20(the minimal repro with assertions on and off, standalone and through gradle; both fix options in a scratch worktree); SHA-1-checked release jars: the minimal repro on 10.6.0 (assertions on and off) and 9.9.0, the finder's 5-test JUnit on 9.9.0, 9.12.3 and 10.5.2, and a release-agnostic probe on 4.9.0 and 4.9.1 (not affected), all 112 releases from 4.10.0 to 10.5.2 and branch_10_64ae946e5a9b. Upstream main2f424fd428da(2026-10-30) still has the unchecked line (read).
- Finding record
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:186-187, (at 645d4fe14a20) :203-204 and :223-224 (constructor javadoc: '@throws ParseException Can be thrown if the content of the files does not meet expected formats'), :1287-1293 (getAliasValue: IndexOutOfBoundsException -> IllegalArgumentException 'Bad flag alias number:'), :1295-1304 (parseMorphAlias: array sized by the declared AM count, undefined slots stay null), :1306-1315 (splitMorphData: 'morphData = morphAliases[alias - 1]' with only NumberFormatException caught, then morphData.isBlank()), :1030 (call from mergeDictionaries) and :1207 (call from readMorphFields)lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/HunspellStemFilterFactory.java:102-122 (at 645d4fe14a20) (inform: new Dictionary(tempDir, "hunspell", affix, dictionaries, ignoreCase); only ParseException is wrapped into IOException 'Unable to load hunspell data!')
- The finding in 30 seconds
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:123-124, :360-361, :1026-1030, :1142, :1205-1207, :1287-1293, :1295-1304, :1306-1315, :268 (at 645d4fe14a20)lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/morphalias.aff, morphalias.dic (at 645d4fe14a20)github.com/hunspell/hunspell man/hunspell.5 (AF and AM entries; read-only GET 2026-11-01)
- Who is affected
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:186-224 (constructors), :1306-1315 (at 645d4fe14a20)lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/HunspellStemFilterFactory.java:58, :96-123 (inform; catch ParseException only at :115-118) (at 645d4fe14a20)
- Why it looks unintended
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:187, :204, :224, :1287-1293 (at 645d4fe14a20)https://github.com/apache/lucene/issues/5049 (LUCENE-3976 comments of 2012-06-01 by Chris Male; read-only GET 2026-11-01)lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/TestDictionary.java:146-164 (testInvalidData) (at 645d4fe14a20)lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/AffixCondition.java:126, :147 (at 645d4fe14a20); git show c5ab3e57b9b (#16919)
- Suggested fix
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:1015 (mergeDictionaries reads through a BufferedReader) (at 645d4fe14a20)
- Prior reports
GitHub API read-only GETs 2026-11-01: repos/apache/lucene/commits/main; contents of Dictionary.java at main; commits?path=Dictionary.java
- TestDictionaryUndefinedMorphAlias.java (JUnit, one test):
repro/LFV-1012/src/org/apache/lucene/analysis/hunspell/TestDictionaryUndefinedMorphAlias.java(46 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-1012/bug.png(475 KB)
![Diagram of the bug: the affix file's AM table declares 1 alias (alias 1 = po:noun) and the dictionary entry foo uses alias 2. Lucene looks up morphAliases[2 - 1] in a 1-slot array with no range check and throws ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1. The same mistake in the AF flag-alias table gives IllegalArgumentException: Bad flag alias number:2. The fix checks the alias against 1 and the number of AM entries defined.](../assets/LFV-1012/bug.png)