A Hunspell .aff can say FLAG UTF-8 (each affix class is named by one Unicode character) and SET ISO8859-2 (the words are Latin-2). Lucene decodes the whole line as Latin-2 first, so a two-byte flag turns into two characters, and an affix rule keeps only the first one.
- Load a Hunspell dictionary whose .aff says
SET ISO8859-2andFLAG UTF-8(both standard values) and has two suffix classes: flagáadds "a", flagâadds "e". The .dic listskot/áandlas/â. - You expect
kotto take only "a" andlasonly "e": acceptkotaandlase, rejectkoteandlasa. C Hunspell does this for all 18 SET values tried, and so does Lucene with SET ISO8859-1 or UTF-8. - Lucene reads the .aff and .dic through an ISO8859-2 decoder before it parses the flags, so
á(bytes C3 A1) becomes the two charactersĂĄandâ(C3 A2) becomesâ. An affix rule keeps only the first character: both rules get the flagĂ(U+0102), and both words carryĂ. - You get
spell("kote")andspell("lasa")= true andstem("kote")=[kot], with no error. On main, 12 of the 18 SET values merge this way, ISO8859-3, -6 and -8 throwUnmappableCharacterExceptionat load, and only no SET, ISO8859-1 and UTF-8 are right.
Users of the opt-in Hunspell module (Dictionary, Hunspell, HunspellStemFilter and its factory) whose dictionary pairs FLAG UTF-8 with an 8-bit SET other than ISO8859-1 and has non-ASCII flags sharing a lead byte (under ISO8859-14, even with different lead bytes). The dictionary loads, but spell checking and stemming overaccept, silently. No public dictionary with this combination is known.
Mechanism, terms and numbers813 words
After a first pass finds the SET and FLAG lines, Lucene's Hunspell Dictionary reads the whole .aff and the .dic through the SET decoder before it parses flags. For FLAG UTF-8 (flags are UTF-8 characters) it undoes that decoding only when the decoder's charset is ISO-8859-1 (LUCENE-9708); every other 8-bit SET that loads gets one flag per decoded character, except ISO8859-14, whose decoder claims to be ISO-8859-1. A two-byte flag such as á (C3 A1) therefore becomes two characters; an affix rule keeps only the first, the lead byte shared by all of U+00C0-U+00FF, while a word keeps both. Affix classes whose flags share a lead byte merge: spell() accepts and the stemmer stems forms the dictionary forbids, with no error. On main this affects 12 of 18 SET values (ISO8859-2, -4, -5, -7, -9, -13, -14, -15, KOI8-R, KOI8-U, microsoft-cp1251, TIS620-2533); ISO8859-3, -6 and -8 fail to load with UnmappableCharacterException. C Hunspell 1.3.2 and 1.7.2 decode such flags from the raw bytes and are right for all 18. All 13 releases run, from 3.5.0 to 10.5.2, and 10.6.0 (run on branch_10_6 at its release revision) mishandle these files (3.5.0-4.7.2 and 8.9.0-10.6.0 silently, 4.8.0-8.8.2 with a load error), and the source history places the releases in between in the same eras. No public dictionary is known to use the combination. A byte-table decoder fixes all three observed symptoms in the probe and the tests, and the module's 1,917 tests pass with it.
- .aff / .dic: a Hunspell dictionary is an affix file (rules such as
SFX á 0 a .: "classáadds the suffix a") and a word list (kot/á: the word kot may take the classes named by flagá). - SET: the encoding of the files' words, for example ISO8859-2 (Latin-2) or KOI8-R. hunspell(5): "Set character encoding of words and morphemes in affix and dictionary files."
- FLAG UTF-8: each flag is one Unicode character written in UTF-8. hunspell(5): "`UTF-8' parameter sets UTF-8 encoded Unicode character flags." C Hunspell decodes these flags from the raw bytes whatever the SET is.
- Lead byte: the first byte of a multi-byte UTF-8 character. All of U+00C0-U+00FF start with C3, all of U+0400-U+043F with D0.
- A first pass finds the SET and FLAG lines (
Dictionary.java:245-260,:837-868); thenDictionaryreads the whole .aff and the .dic through a reader that decodes the SET charset (:348,:1015), so flags arrive as SET-decoded characters. getFlagParsingStrategy(Dictionary.java:922-944) gives FLAG UTF-8 the strategy that turns those characters back into bytes and reads them as UTF-8 (DefaultAsUtf8FlagParsingStrategy) only if the decoder's charset equals ISO-8859-1 (:932). Every other 8-bit SET that loads getsSimpleFlagParsingStrategy(:935): one flag per decoded character, except ISO8859-14 (step 5).- An affix rule takes one flag with
parseFlag, which returns the first character and ignores the rest becausecheckFlags = false(:1356,:1364-1370). A word takes all characters (:1130). - So with SET ISO8859-2,
á(C3 A1) reads asĂĄandâ(C3 A2) asâ: both rules become classĂ, and the words carry[Ă, Ą]and[Ă, ˘]. Each word now takes both suffixes. - ISO8859-14 is read by
ISO8859_14Decoder, which reports ISO-8859-1 as its charset (ISO8859_14Decoder.java:45), so it takes the ISO-8859-1 branch; re-encoding turns the bytes its table remaps into?, and both rules get U+FFFD. Under ISO8859-14 flags merge even when their lead bytes differ:á(C3 A1) andġ(C4 A1) stay apart under ISO8859-2 and -15 but merge under ISO8859-14 (executed, assertions on and off). - A flag byte that the SET leaves unassigned (C3 in ISO8859-3 and -8, A1 in ISO8859-6 and -8) makes the reader throw
UnmappableCharacterExceptionwhile the line is read (:350), because the decoder replaces only malformed input (:910-912).
| SET | rule "-a" | rule "-e" | kot | las | kote | lasa | stem(kote) |
|---|---|---|---|---|---|---|---|
| ISO8859-1, UTF-8, none | U+00E1 | U+00E2 | U+00E1 | U+00E2 | rejected | rejected | [] |
| ISO8859-2 | U+0102 | U+0102 | U+0102 U+0104 | U+0102 U+02D8 | accepted | accepted | [kot] |
| ISO8859-15 | U+00C3 | U+00C3 | U+00A1 U+00C3 | U+00A2 U+00C3 | accepted | accepted | [kot] |
| KOI8-R | U+0446 | U+0446 | U+0446 U+2551 | U+0446 U+2552 | accepted | accepted | [kot] |
| ISO8859-14 | U+FFFD | U+FFFD | U+003F U+FFFD | U+003F U+FFFD | accepted | accepted | [kot] |
| ISO8859-3, -6, -8 | load fails: UnmappableCharacterException: Input length = 1 |
ISO8859-15 decodes C3, A1 and A2 exactly like ISO8859-1 and still merges: only the charset's identity picks the strategy. Under ISO8859-2 and the 10 other SETs that take SimpleFlagParsingStrategy, a merge needs two flags with the same lead byte; under ISO8859-14 it needs flags whose bytes its table remaps, whatever their lead bytes. The 18 values are a selection: hunspell(5) also lists ISO8859-10, which Lucene cannot load at all (sibling bug LFV-928), and ISCII-DEVANAGARI, which was not tested.
Exposure and likelihood224 words
- Who is exposed. Any code that loads such a dictionary through the public
Dictionaryconstructors:Hunspellspell checking,HunspellStemFilter, andHunspellStemFilterFactory, which builds the sameDictionary(by reading; not run). Within the module no setting is needed: the two directives alone trigger it. - What the damage looks like. Silent: affix classes merge, so
spell()accepts forbidden forms and the stemmer returns extra stems (for examplekote→kot), which then become index and query terms. For ISO8859-3, -6 and -8 (with these flags) the dictionary does not load at all, withUnmappableCharacterException: Input length = 1and no file or line. - What it takes. Under ISO8859-2 and the 10 other SETs that read one flag per character: two flags with the same UTF-8 lead byte, which is common for non-ASCII flags (U+00C0-U+00FF share C3, U+0400-U+043F share D0). Under ISO8859-14: flags with bytes its decoder remaps, even with different lead bytes. Deterministic: no scale, randomness or concurrency; assertions on and off behave the same.
- How likely. Rare. The finder found 0 of 191 public dictionaries with this combination (not re-checked); in a 99-file LibreOffice snapshot FLAG UTF-8 appears only with SET UTF-8 (24 files) or with no SET (pt_BR, the case LUCENE-9708 handled). Mixing an 8-bit encoding with UTF-8 flags is a real pattern, though: pt_BR does it with the default encoding.
The test shows that with SET ISO8859-2 and FLAG UTF-8, the suffix classes U+00E1 (-a) and U+00E2 (-e) share the UTF-8 lead byte 0xC3; Lucene parses flags from ISO8859-2-decoded text, so both affix flags become that lead byte and kot/á takes the -e suffix too.
/*
* 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.nio.charset.StandardCharsets;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestFlagUtf8With8BitSet extends LuceneTestCase {
public void testUtf8FlagsSharingLeadByteStayDistinct() throws Exception {
String aff =
"SET ISO8859-2\n"
+ "FLAG UTF-8\n"
+ "SFX á Y 1\n"
+ "SFX á 0 a .\n"
+ "SFX â Y 1\n"
+ "SFX â 0 e .\n";
String dic = "1\nkot/á\n";
Dictionary dictionary =
new Dictionary(
new ByteBuffersDirectory(),
"dict",
new ByteArrayInputStream(aff.getBytes(StandardCharsets.UTF_8)),
new ByteArrayInputStream(dic.getBytes(StandardCharsets.UTF_8)));
assertFalse(
"expected kote to be rejected (kot has only flag U+00E1, suffix e needs U+00E2),"
+ " but spell(\"kote\") returned true",
new Hunspell(dictionary).spell("kote"));
}
}
# in a copy of this finding's repro folder (repro/LFV-932/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.analysis.hunspell.TestFlagUtf8With8BitSet
- Expected vs actual
- Expected
spell("kote")false, as C Hunspell answers (kot has only flag U+00E1); actual: true.
More files, environment, full output48 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected kote to be rejected (kot has only flag U+00E1, suffix e needs U+00E2), but spell("kote") returned true
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.assertTrue(Assert.java:42)
org.junit.Assert.assertFalse(Assert.java:65)
org.apache.lucene.analysis.hunspell.TestFlagUtf8With8BitSet.testUtf8FlagsSharingLeadByteStayDistinct(TestFlagUtf8With8BitSet.java:41)- hunspell(5) defines
FLAG UTF-8flags as UTF-8 characters with no SET condition, and C Hunspell decodes them from the raw bytes for every SET. - Lucene's LUCENE-9708 is titled "support FLAG UTF-8 in absence of SET UTF-8" but handled only ISO-8859-1.
- ISO8859-15 decodes the same bytes exactly like ISO8859-1 yet merges; no doc or test excludes other SETs.
The full argument247 words
- Contract. hunspell(5): SET is the encoding "of words and morphemes in affix and dictionary files"; FLAG UTF-8 "sets UTF-8 encoded Unicode character flags". C Hunspell's
decode_flagsanddecode_flag(caseFLAG_UNI) callu8_u16on the flag bytes with no SET check; C Hunspell 1.3.2 and 1.7.2 give the expected verdicts for all 18 SET values on byte-identical files. - Lucene's own intent. LUCENE-9708 (
7a7949aed2a, PR apache/lucene-solr#2270): title "Hunspell: support FLAG UTF-8 in absence of SET UTF-8", body "This is a valid situation to be supported". (Its first javadoc said "Used for FLAG UTF-8 on non-UTF-8 encoding", but that code was already ISO-8859-1-only and the wording was narrowed in review to match, so it is weak evidence.) The ISO-8859-1 limit follows from the re-encode trick (getBytes(ISO_8859_1)restores the bytes only for ISO-8859-1). - Javadoc.
Hunspell"can be used in place of native Hunspell for many languages" and lists what is unsupported (Hungarian, non-BMP characters, PHONE); this combination is not listed.SimpleFlagParsingStrategysays it "Can be used with both the ASCII and UTF-8 flag types", true only for UTF-8 text. Lucene neither rejects nor warns. - Tests.
TestDictionary.testUtf8Flagcovers FLAG UTF-8 only with the default charset,testFlagWithCrazyWhitespaceonly with UTF-8;flagutf8.affhas no SET line. No test pins the merged behaviour. - Behaviour varies by accident. Correct for ISO8859-1, silently wrong for ISO8859-15, which decodes these bytes identically, and a load failure for ISO8859-3.
- Precedent. LUCENE-5824 (#6886) fixed
FLAG longflags collapsing, with the same overgeneration symptom, as a bug.
The devil's-advocate check193 words
The strongest "intended" case (the devil's-advocate verifier; the intended-lens review by another model family rated it moderate): LUCENE-9708 scoped UTF-8 flags to the default encoding on purpose (its private javadoc was narrowed in review to "even if the rest of the file is in the default (8-bit) encoding"), and SET can be read as the encoding of the whole file, flags included.
Why it fails: hunspell(5) restricts SET to words and morphemes and defines UTF-8 flags without a SET condition; C Hunspell decodes them from bytes for every SET; the same PR calls FLAG UTF-8 without SET UTF-8 "a valid situation to be supported"; the ISO-8859-1 limit is a property of the round-trip trick; no javadoc, test, CHANGES or MIGRATE entry excludes other SETs; and the outcome flips between ISO8859-1 and ISO8859-15 for identical bytes. Verdict: a defect (adjudication: confirmed, high). What the counter-case gets right: hunspell(5) and C Hunspell are strong compatibility evidence, not a Lucene API guarantee; the first-commit javadoc ("on non-UTF-8 encoding") sat on code that was already ISO-8859-1-only, so it says little; and no public dictionary is known to use the combination, so this is a low-priority correctness fix.
Recommended Decode FLAG UTF-8 files through a byte table that keeps every byte
When FLAG UTF-8 meets a single-byte SET other than ISO-8859-1, read the files through a byte table built from the SET decoder (unassigned bytes become private-use characters) and turn flag characters back into bytes before decoding them as UTF-8. All 8 repro tests and the module's 1,917 tests pass.
} else if ("UTF-8".equals(flagType)) {
if (decoder instanceof ByteTableDecoder byteTable) {
return new EightBitAsUtf8FlagParsingStrategy(byteTable);
}
Charset charset = decoder.charset();
if (DEFAULT_CHARSET.equals(charset) && !(decoder instanceof ISO8859_14Decoder)) {
return new DefaultAsUtf8FlagParsingStrategy();
}
if (isSingleByte(charset)) {
return new EightBitAsUtf8FlagParsingStrategy(new ByteTableDecoder(decoder));
}
return new SimpleFlagParsingStrategy();
}All options and trade-offs3 options · 752 words
Option A (smaller) fixes the silent merge and the ISO8859-14 case but not the ISO8859-3/-6/-8 load failure; option B fixes all three in the probe and the tests. Both change the same method as open PR #16422 (coordinate with it), which rewrites getFlagParsingStrategy as a switch with the same ISO-8859-1-only test. Whichever is chosen, add the FLAG UTF-8 + 8-bit SET cases to the hunspell tests.
Decode FLAG UTF-8 files through a byte table that keeps every byte (validated by the checks) (recommended)
A new package-private ByteTableDecoder (70 lines) decodes each byte through a 256-entry table built once from the SET decoder (a byte the SET leaves unassigned becomes U+F700 + byte instead of an error) and maps decoded text back to its bytes. getFlagParsingStrategy uses it for FLAG UTF-8 with a single-byte SET other than plain ISO-8859-1 (ISO8859-14 included); readConfig then reads the .aff and .dic through it; the new EightBitAsUtf8FlagParsingStrategy decodes the recovered flag bytes as UTF-8. +107/-3 in two files.
} else if ("UTF-8".equals(flagType)) {
if (decoder instanceof ByteTableDecoder byteTable) {
return new EightBitAsUtf8FlagParsingStrategy(byteTable);
}
Charset charset = decoder.charset();
if (DEFAULT_CHARSET.equals(charset) && !(decoder instanceof ISO8859_14Decoder)) {
return new DefaultAsUtf8FlagParsingStrategy();
}
if (isSingleByte(charset)) {
return new EightBitAsUtf8FlagParsingStrategy(new ByteTableDecoder(decoder));
}
return new SimpleFlagParsingStrategy();
}Trade-offs
- Only dictionaries with FLAG UTF-8 and a single-byte SET other than ISO-8859-1 take the new path; no SET, ISO-8859-1 and UTF-8 dictionaries are untouched. Multi-byte non-UTF-8 charsets (not Hunspell SET values) keep today's behaviour.
- In that configuration the whole .aff and .dic, words included, are decoded through the new table (built once from the SET decoder) instead of that decoder, so it needs compatibility tests on real dictionaries beyond these synthetic flags and words. A word byte that the SET leaves unassigned now loads as a private-use character instead of failing the load (unassigned bytes inside words were not tested). The table would collide only if a SET charset mapped a byte into U+F700-U+F7FF; none of the 17 single-byte charsets checked in Python's codec tables does, and the 15 of them that the JDK provides were also checked in Java 25 (no byte in that range, no duplicate chars); other Java charsets that pass the single-byte gate were not checked.
- One map lookup per flag character, at load time only. Larger than option A (a new class plus a decoder swap in
readConfig).
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, JDK 25: the new test fails 7 of 8 on stock (assertions on and off) and passes 8 of 8 with the fix;org.apache.lucene.analysis.hunspell.*: 47 suites, 166 tests (13 skipped: they need external dictionaries), 0 failures, assertions on (seed 5EED0932) and off (seed 5EED0933); the whole:lucene:analysis:common:test: 340 suites, 1,917 tests, 18 skipped, 0 failures;:lucene:analysis:common:check -x testpasses (ecjLint, forbiddenApis, google-java-format, licenses). - Standalone runner with the fix shadowed: the new test 8 of 8 (
-ea,-da), the finder's test 5 of 5, the independent reproducer's 11 of 11; the fix probe gives the expected flags and verdicts for all 12 SET values it runs (ISO8859-3, -6, -8 and -14 included); not shown for other single-byte Java charsets or for real dictionaries.
Re-encode the flag text with the SET charset, then decode UTF-8 (the finder's sketch) (validated by the checks)
For FLAG UTF-8 with any non-UTF-8 SET, a new strategy rebuilds the bytes with rawFlags.getBytes(setCharset) (an inverse table ISO8859_14Decoder.encode for ISO8859-14) and decodes them as UTF-8; the decoder is unchanged. +55/-3 in two files.
if (DEFAULT_CHARSET.equals(charset) && !(decoder instanceof ISO8859_14Decoder)) {
return new DefaultAsUtf8FlagParsingStrategy();
}
if (!StandardCharsets.UTF_8.equals(charset)) {
return new EightBitAsUtf8FlagParsingStrategy(decoder); // getBytes(SET charset), then UTF-8
}Trade-offs
Smaller and does not change how words are decoded. Leaves the ISO8859-3/-6/-8 load failure, because those lines fail to decode before flags are parsed. A one-method variant without the ISO8859_14Decoder change cut the independent reproducer's failures from 7 to 2 (ISO8859-14 and ISO8859-3 remained).
What the checks validated
Gradle, hunspell package: 166 tests, 1 failure (the new testSetIso8859_3); the format check passes after re-wrapping one over-long comment. Standalone: the new test 7 of 8, the independent reproducer's 10 of 11 (its ISO8859-3 test fails); earlier the finder's test 4 of 5 and 146 hunspell tests green.
Throw a ParseException naming the FLAG and SET lines when FLAG UTF-8 meets a SET the code cannot handle. Not run.
Trade-offs
Turns the silent merge into a loud failure, but refuses files that C Hunspell reads correctly, and is no simpler than option A.
Products that load Hunspell dictionaries through HunspellStemFilterFactory or Dictionary inherit the merged classes for such files; the files are configuration that Lucene trusts as given, and query or document text cannot trigger the merge.
A differential test loaded one FLAG UTF-8 dictionary under 18 SET values through the stock jar and compared spell verdicts with C Hunspell 1.3.2 and 1.7.2 on byte-identical files: 12 values merged the flags and 3 failed to load, while C Hunspell was right for all 18. Code reading then found the ISO-8859-1-only branch.
The method in full127 words
Method: differential testing against a reference implementation (the study's "semi-formal" tier), a side leg of a hunt unit that compared Lucene's handling of Hunspell SET encodings with C Hunspell, 2026-10-28.
- A probe built one .aff/.dic pair (FLAG UTF-8, classes U+00E1 and U+00E2) and varied only the SET line over 18 values: none, ISO8859-1 to -9, -13, -14, -15, KOI8-R, KOI8-U, microsoft-cp1251, TIS620-2533 and UTF-8. Lucene was right for 3, merged 12 and failed to load 3; C Hunspell 1.3.2 (the command-line tool) and 1.7.2 (the library, raw-byte driver) were right for all 18.
- Code reading of
getFlagParsingStrategyexplained the split; an AI finder then wrote a 5-test JUnit (public API plus the package-privateStemmer), and independent AI tools reproduced, reviewed and judged it (see the checks).
No prior report found (best-effort search, 2026-10-28): 286 queries over GitHub issues, PRs and commits, the JIRA archive, the Lucene and Solr mailing lists, downstream trackers and the web, plus the changed-file lists of all 413 open PRs. The closest item is LUCENE-9708 itself, the change that left the gap.
Searches4 searches
Nothing reports that UTF-8 flags are mis-parsed under an 8-bit SET other than ISO8859-1. Upstream main 645d4fe14a20 (2026-10-28) still has the ISO-8859-1-only test.
Related, not the same bug: #10747 / PR apache/lucene-solr#2270 (LUCENE-9708, the feature; its one review thread is about ISO-8859-1 only); #6886 (LUCENE-5824, FLAG long collapse, another fix site: precedent); #8431 (LUCENE-7378, hu_HU differential, SET UTF-8); #6580 (LUCENE-5517) and #10763 (LUCENE-9724), which changed the symptom; open #16421 / PR #16422 (a switch refactor of the same method with the same behaviour; coordinate a fix with it).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr) | 2026-10-28 | part of the 286 queries recorded in the prior-report record: two runs of a scripted prior-report search (85 and 75 queries across all sources), 76 manual searches incl. Elasticsearch, OpenSearch, Solr, Lucene.NET, hunspell/hunspell, wooorm/dictionaries and all of GitHub, 3 code searches | no report; closest #10747 (the feature itself), #6886 (precedent) |
| Open PRs | 2026-10-28 | the changed-file lists of all 413 open apache/lucene PRs (for 4 PRs with more than 100 files, only the first 100 paths) | only #16422 touches hunspell: a switch refactor of getFlagParsingStrategy with the same ISO-8859-1-only test |
| JIRA archive and mailing lists | 2026-10-28 | 9 JQL searches (LUCENE, SOLR); list queries over dev@, java-user@, issues@, commits@, solr-user@, general@ and 16 Solr-list searches | no report |
| Web | 2026-10-28 | web queries ingested by the scripted prior-report search | no report; only verbatim copies of Lucene's source and C Hunspell references |
The root cause dates from LUCENE-3414 (2011, first released in 3.5.0). LUCENE-5517 (4.8.0) turned it into a load error; LUCENE-9708 fixed only ISO-8859-1 and LUCENE-9724 made it silent again (both 2021, in 8.9.0 and 9.0.0). All 13 releases run from 3.5.0 to 10.5.2 mishandle these files; the source history places the releases in between in the same eras.
Versions tested6 rows
Introduced by e3172b9239c (trunk) / 1e084f1d08e (branch_3x), "LUCENE-3414: Added Hunspell for Lucene", 2011-09-10: FLAG UTF-8 parsed one flag per SET-decoded char. a4b6ef84dd0 / 2c98dd5b320 (LUCENE-5517, 2014-03-11, 4.8.0) made multi-char affix flags a load error. 7a7949aed2a (LUCENE-9708, 2021-02-01) added the UTF-8 decoding for ISO-8859-1 only; 1cc26b6bb44 (LUCENE-9724, 2021-02-07) set checkFlags = false, which turned the load error back into the silent merge. Both 2021 commits shipped in 9.0.0 and, through the bulk backport c6b964a1157 (LUCENE-9687), in 8.9.0. Restoring only checkFlags = true on 9.0.0 brings the load error back. Never fixed.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the brief's test 7 of 8 fail (gradle and the standalone runner, assertions on and off) |
| 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 | affected | ISO8859-1 correct; ISO8859-2, -15, -14 merged silently; ISO8859-3 load error (probe); the finder's test 4 of 5 fail (an import-adapted copy on 9.0.0, 8.11.4 and 8.9.0); the brief's test 7 of 8 on 9.12.3 and 10.5.2 |
| 4.8.0, 4.10.4, 5.5.5, 7.7.3, 8.8.2 | load error | every 8-bit SET, ISO8859-1 included: IllegalArgumentException "expected only one flag" (ISO8859-3: unmappable byte) |
| 3.5.0, 3.6.2, 4.7.2 | affected | ISO8859-1, -2 and -15 merged; ISO8859-14 unsupported; ISO8859-3 load error |
| 3.4.0 | API absent | no Hunspell module: the probe does not compile |
| main + fix B | not affected | the brief's test 8 of 8; 166 hunspell tests and the module's 1,917 tests, 0 failed |
An independent reproducer, a devil's advocate and an adjudication found it real (adjudication: confirmed, high); a reviewer from another model family agreed reading the code, and a second review of that family could not run. A registry re-run and an audit reproduced it with new seeds.
Checks and verdicts371 words
Every executed check ran its own or the finder's test against the stock jars, with assertions on and off unless noted. No cross-model review has executed code; both only read it.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | A 5-test JUnit (public API plus the package-private Stemmer) fails 4 of 5 on main (-ea, -da), branch_10_6, 10.5.2, 9.12.3, and (import-adapted) 9.0.0 and 8.11.4 (the 8.9.0 run is the version-history runs's); the ISO8859-1 control passes; C Hunspell 1.3.2 and 1.7.2 right for all 18 SET values. |
| cross-model review, refute lens | procedural (did not run) | An independent reviewer from another model family (OpenAI GPT-5.6): it could not run code and voted not real on procedure only, a vote that, by its own notes, does not disprove the behaviour. A separate run re-ran the repro: 4 of 5 fail. |
| Independent reproducer | real | Own 11-test JUnit (other words, flags and SET values) fails 7 of 11 on main (-ea, -da), 10.5.2 and 9.12.3; 4 controls pass; C Hunspell 1.3.2 agrees on byte-identical files. |
| Cross-model review, intended lens | real (medium) | OpenAI GPT-5.6 with read-only tools (reading only): the intended-behaviour argument is moderate and rejected; the root cause agrees; a separate run re-ran the repro with -da: 4 of 5 fail. |
| Devil's advocate | real | Built the strongest intended case (LUCENE-9708's deliberate scope) and rated it weak; re-ran the repro (4 of 5, -ea and -da); C Hunspell 1.3.2 on its own files agrees. |
| adjudication | confirmed (high) | Re-ran the independent reproducer's test with a new seed (7 of 11 fail, -ea and -da) and read the root cause on main. |
| security assessment | none | The trigger is dictionary configuration, trusted as given; query and document text cannot cause or steer it; no memory, CPU or data-exposure effect. |
| registry re-run and audit | keep | The finder's test 4 of 5 fail and the reproducer's 7 of 11 fail on main with new seeds; the cited lines re-read at a9779ef3c193. |
| brief runs | reproduced | The brief's test through gradle (main) and the standalone runner (main, 10.5.2, 9.12.3); options A and B validated; the probe shows the stored flags. |
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 500 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,577 words
- Registry title
- Hunspell Dictionary with FLAG UTF-8 and an 8-bit SET other than ISO8859-1 silently merges UTF-8 flags that share a lead byte, so spell() and the stemmer accept forms the dictionary forbids (some SET values fail to load instead)
- Area
- analysis / hunspell (Dictionary FLAG UTF-8 parsing under the SET encoding)
- Priority
- Low, rank 460: The opt-in Hunspell module silently merges UTF-8 flag classes so spell and stem accept forbidden forms, but only for FLAG UTF-8 with an 8-bit SET other than ISO8859-1, which no surveyed public dictionary uses.
- Impact class
- silent wrong results
- Impact note
- Only applications that use the opt-in Hunspell module (a Dictionary built directly, through HunspellStemFilterFactory, or for the Hunspell spell-checker) with an affix file that pairs FLAG UTF-8 with an 8-bit SET other than ISO8859-1 are hit: the dictionary loads with no error, but UTF-8 flags that share their first byte (all of U+00C0-U+00FF share 0xC3, all of U+0400-U+043F share 0xD0) become one affix class, so spell() accepts and the stemmer stems word forms the dictionary forbids, deterministically and with no scale needed; for some SET/flag pairs (ISO8859-3, -6, -8 with Latin-1 flags) the dictionary fails to load instead; no public dictionary with this combination is known, and the silent form is in 3.5.0-4.7.2 and 8.9.0-10.6.0.
- Summary
- You load a Hunspell dictionary whose affix file says FLAG UTF-8 (flags are UTF-8 characters) and SET ISO8859-2, KOI8-R, microsoft-cp1251 or another 8-bit encoding except ISO8859-1, with two suffix classes whose flags are, say, U+00E1 and U+00E2, and expect what C Hunspell 1.3.2 and 1.7.2 do on the same bytes: a word that carries only the first flag takes only the first suffix. Instead Lucene reads the flags from text already decoded with the SET charset, so each 2-byte flag becomes two characters and an affix rule keeps only the first one, the shared lead byte: both classes merge, spell() accepts the forbidden forms (kote, lasa in the repro) and the stemmer returns kot for kote, with no error. Only no SET, SET ISO8859-1 and SET UTF-8 work; ISO8859-14 merges too (its decoder reports ISO-8859-1 as its charset, and the re-encode turns remapped flag bytes into '?'), and with ISO8859-3, -6 or -8 the load throws UnmappableCharacterException because a flag byte is unassigned in that charset.
- Introduced
- e3172b9239c (trunk) / 1e084f1d08e (branch_3x), LUCENE-3414 'Added Hunspell for Lucene' (apache/lucene#4487, no PR, SVN era), first released in 3.5.0: FLAG UTF-8 mapped to SimpleFlagParsingStrategy (one flag per char of the line already decoded with the SET charset). The symptom then changed twice: a4b6ef84dd0 (branch_4x) / 2c98dd5b320 (trunk), LUCENE-5517 (#6580, 4.8.0) made parseFlag throw 'expected only one flag', so the same dictionaries were rejected at load; then 7a7949aed2a, LUCENE-9708 (#10747, PR apache/lucene-solr#2270) added DefaultAsUtf8FlagParsingStrategy only for the ISO-8859-1 charset, and 1cc26b6bb44, LUCENE-9724 (#10763, PR apache/lucene-solr#2307) set checkFlags = false, which turned the load error back into the silent merge for every other 8-bit SET. Both 2021 commits first shipped in 9.0.0 and, through the LUCENE-9687 bulk backport c6b964a1157 (PR apache/lucene-solr#2499), in 8.9.0. The fix site (getFlagParsingStrategy / the flag strategies) is LUCENE-3414 code patched for ISO-8859-1 only by LUCENE-9708; LUCENE-9724 only changed the symptom. [read: git history in the full-history clone; bracketed by execution: 3.4.0 has no Hunspell API, 3.5.0 merges, 4.8.0-8.8.2 reject at load, 8.9.0 merges; a 9.0.0 run with only checkFlags = true restored rejects at load again] (2011-09-10 (LUCENE-3414, author date); current silent form 2021-02-01 (LUCENE-9708) and 2021-02-07 (LUCENE-9724), author dates; 8.x backport 2021-05-28)
- First affected version
- 3.5.0 [exec] (tagged 2011-11-25), the first release with the Hunspell module; 3.4.0 has no Hunspell API [exec]. Current silent form since 8.9.0 (8.x line) and 9.0.0 [exec]; 4.8.0-8.8.2 reject the same dictionaries at load instead [exec]
- Affected versions
- Every release that has the Hunspell module, 3.5.0 to 10.6.0 (10.6.0 run as its release revision, branch_10_6 4ae946e5a9b), and main a9779ef3c193, with three symptoms for FLAG UTF-8 plus an 8-bit SET other than ISO8859-1 [exec: version-bridging stem probe on 3.5.0, 3.6.2, 4.7.2, 4.8.0, 4.10.4, 5.5.5, 7.7.3, 8.8.2, 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main, SET UTF-8 control OK in every run; the finder's JUnit fails 4 of 5 on 8.9.0, 8.11.4 (import-adapted copy), 9.0.0, 9.12.3, 10.5.2, branch_10_6 and main (-ea and -da); the independent reproduction's JUnit fails 7 of 11 on 9.12.3, 10.5.2 and main (-ea and -da)]. 3.5.0-4.7.2: flags silently merged, even for ISO8859-1 (tested ISO8859-1, -2 and -15; ISO8859-14 is not a supported charset there). 4.8.0-8.8.2: the same dictionaries rejected at load with IllegalArgumentException 'expected only one flag', ISO8859-1 included (tested ISO8859-1, -2, -14 and -15). 8.9.0-8.11.4 and 9.0.0-main: ISO8859-1 and no SET correct; ISO8859-2, -4, -5, -7, -9, -13, -14, -15, KOI8-R, KOI8-U, microsoft-cp1251 and TIS620-2533 silently merge (main, 18-value probe). ISO8859-3 fails to load in every release tested. [read] getFlagParsingStrategy is unchanged on upstream main 645d4fe14a20 (2026-10-28); open PR #16422 rewrites it as a switch with the same ISO-8859-1-only test. Not fixed upstream. All runs on Linux aarch64 with JDK 25.0.3
- 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 (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal; also reached by code reading
- Upstream and prior reports
- none found (2026-10-28, read-only; the prior-report record, verdict new): 286 queries (two scripted prior-report searches: 85 and 75 queries over GitHub issues/PRs, commits, downstream trackers, dev@, java-user@, issues@, commits@, solr-user@, general@ and the Solr lists, the JIRA archive and the web; 76 manual GitHub search queries over apache/lucene, Elasticsearch, OpenSearch, Solr, Lucene.NET, hunspell/hunspell, wooorm/dictionaries and all of GitHub; 3 code searches; 9 JQL searches; 16 Solr-list searches), the finder's 53 queries, the independent reproduction's and the intended-behaviour check's own GitHub legs, and a scan of all 413 open apache/lucene PRs with their changed-file lists. Closest items, none this bug: #10747 / PR apache/lucene-solr#2270 (LUCENE-9708, the feature that added the ISO-8859-1-only path; empty issue body, one review thread about ISO-8859-1 only: the origin, not a report); #6886 (LUCENE-5824, FLAG long flags collapse, another flag type and fix site: precedent that collapsed flags are a bug); #8431 (LUCENE-7378, hu_HU differential, SET UTF-8); #6580 (LUCENE-5517) and #10763 (LUCENE-9724), the commits that changed the symptom, neither mentions UTF-8 flags under another SET (re-read at the registration check); open #16421 / PR #16422 (switch refactor; its diff rewrites getFlagParsingStrategy's body as a switch with the same ISO-8859-1-only test: neither a report nor a fix, but a fix would conflict with it). Not fixed upstream: getFlagParsingStrategy unchanged on upstream main 645d4fe14a20; last Dictionary.java change 224f580a5d5 (#16313, author and commit date 2026-07-21), ISO8859_14Decoder.java last changed 2021-08-25 (author date). Refresh (2026-10-28): apache/lucene hunspell issues/PRs updated since 2026-10-26: only #16919 (merged, AffixCondition only); '"FLAG UTF-8"' 4 hits (#10747, #6568, #6886, and #9508 whose only match is an unrelated build-file attribute); 'hunspell "SET ISO8859"' 0 hits; 'hunspell flags encoding' 4 old unrelated hits (#9418, #6531, #6886, #6570); no open PR updated since 2026-10-26 touches hunspell.
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-28) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars (the brief's test); the version-history runs's version-bridging stem probe on 3.4.0 (no API), 3.5.0, 3.6.2, 4.7.2, 4.8.0, 4.10.4, 5.5.5, 7.7.3, 8.8.2, 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main, plus a 9.0.0 run withcheckFlags = true; C Hunspell 1.3.2 and 1.7.2 on byte-identical files. Fix B was validated in the scratch worktree (the new test, the 166 hunspell tests with assertions on and off, the module's 1,917 tests,check -x test); fix A with the hunspell tests and the format check.
- Finding record
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:922-944 (getFlagParsingStrategy; line 932 DEFAULT_CHARSET.equals(charset), line 935 SimpleFlagParsingStrategy), 910-912 (replacingDecoder: only malformed input replaced), 1356 (checkFlags = false), 1364-1370 (parseFlag keeps flags[0]), 1408-1433 (SimpleFlagParsingStrategy, DefaultAsUtf8FlagParsingStrategy) and ISO8859_14Decoder.java:45 (reports ISO_8859_1, 'fake with similar properties') [read at the registration check]GitHub, read-only, registration check 2026-10-28: search/issues 'repo:apache/lucene hunspell updated:>=2026-10-26' (1: #16919), 'repo:apache/lucene "FLAG UTF-8"' (4: #10747, #9508, #6568, #6886), 'repo:apache/lucene hunspell "SET ISO8859"' (0), 'repo:apache/lucene hunspell flags encoding' (4: #9418, #6531, #6886, #6570); issues #6580, #10763 and PR apache/lucene-solr#2307 re-read; PR #16422 files (switch rewrite of getFlagParsingStrategy, same test); commits for Dictionary.java (latest 224f580a5d5, 2026-07-21) and ISO8859_14Decoder.java (latest 8ac26737913, 2021-08-25); open PRs updated since 2026-10-26 (none on hunspell)
- The finding in 30 seconds
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:348 (at a9779ef3c193) (affix reader), :1015 (dic reader), :922-944 (getFlagParsingStrategy; :932 DEFAULT_CHARSET.equals, :935 SimpleFlagParsingStrategy), :1354-1370 (checkFlags = false, parseFlag returns flags[0]), :723 (rule flag), :1130 (word flags), :910-912 (replacingDecoder), :350 (exception site), :1406-1436 (strategies)lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/ISO8859_14Decoder.java:28-42 (at a9779ef3c193) (table), :45 (reports ISO_8859_1)lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:245-260, (at a9779ef3c193) :837-868 (prologue scan with streamCharset; readConfig)
- Who is affected
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/HunspellStemFilterFactory.java:111; (at a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:189-230 (at a9779ef3c193) (public constructors)
- Why it looks unintended
lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:34-47; (at a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:1404-1407, (at a9779ef3c193) :1420lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/TestDictionary.java:322-335; (at a9779ef3c193) lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/flagutf8.aff (at a9779ef3c193)
- TestFlagUtf8With8BitSet.java (JUnit test):
repro/LFV-932/src/org/apache/lucene/analysis/hunspell/TestFlagUtf8With8BitSet.java(46 lines)
A light, step-by-step picture of the repro (two suffix classes, two words, SET ISO8859-2), made in parallel with this doc from executed runs on main a9779ef3c193 and the code.
assets/LFV-932/bug.png(500 KB)
