All findings · Lucene finding · LFV-932 · hunspell-flag-utf8-with-8bit-set-merges-flags

Hunspell FLAG UTF-8 with an 8-bit SET other than ISO8859-1 merges affix classes, so spell() and the stemmer accept forms the dictionary forbids

With FLAG UTF-8 and an 8-bit SET such as ISO8859-2, Lucene's Hunspell Dictionary splits each two-byte flag into two characters and affix rules keep only the first, so the classes á and â merge: kote is accepted and stems to kot, silently. ISO8859-3, -6 and -8 fail to load.

Summary

Priority
Low, rank 460The 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
silent wrong results
Since
3.5.0 (2011); the silent form since 8.9.0 and 9.0.0 (2021)introduced 2011-09-10 · over 15 years
Affected
3.5.0 to 10.6.0 and main (4.8.0 to 8.8.2 fail at load instead; 10.6.0 run on branch_10_6 at its release revision)
Status
documented
Found by
differential test against C Hunspell over 18 SET values

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Diagram of Lucene's Hunspell Dictionary reading a FLAG UTF-8 affix file under SET ISO8859-2. The flag á (bytes C3 A1) is decoded as the two characters Ă Ą and the flag â (C3 A2) as Ă ˘. Each suffix rule keeps only its first character, so both rules become class Ă; the words kot and las both carry Ă, so kote and lasa are accepted and kote stems to kot. With SET ISO8859-1 or UTF-8, or with the fix, the flags stay á and â and the forms are rejected.
How one FLAG UTF-8 dictionary reads under SET ISO8859-2: the flags á and â are split into SET-decoded characters, each affix rule keeps only the shared first character Ă, and the two suffix classes merge; then the recommended fix (option B), which keeps the bytes. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Load a Hunspell dictionary whose .aff says SET ISO8859-2 and FLAG UTF-8 (both standard values) and has two suffix classes: flag á adds "a", flag â adds "e". The .dic lists kot/á and las/â.
  2. You expect kot to take only "a" and las only "e": accept kota and lase, reject kote and lasa. C Hunspell does this for all 18 SET values tried, and so does Lucene with SET ISO8859-1 or UTF-8.
  3. 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 Ă.
  4. You get spell("kote") and spell("lasa") = true and stem("kote") = [kot], with no error. On main, 12 of the 18 SET values merge this way, ISO8859-3, -6 and -8 throw UnmappableCharacterException at load, and only no SET, ISO8859-1 and UTF-8 are right.

Who is affected

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.

Terms

  • .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.

The bug

  1. A first pass finds the SET and FLAG lines (Dictionary.java:245-260, :837-868); then Dictionary reads the whole .aff and the .dic through a reader that decodes the SET charset (:348, :1015), so flags arrive as SET-decoded characters.
  2. 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 gets SimpleFlagParsingStrategy (:935): one flag per decoded character, except ISO8859-14 (step 5).
  3. An affix rule takes one flag with parseFlag, which returns the first character and ignores the rest because checkFlags = false (:1356, :1364-1370). A word takes all characters (:1130).
  4. 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.
  5. 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).
  6. A flag byte that the SET leaves unassigned (C3 in ISO8859-3 and -8, A1 in ISO8859-6 and -8) makes the reader throw UnmappableCharacterException while the line is read (:350), because the decoder replaces only malformed input (:910-912).

The numbers (main, flags stored by Dictionary, as code points)

SETrule "-a"rule "-e"kotlaskotelasastem(kote)
ISO8859-1, UTF-8, noneU+00E1U+00E2U+00E1U+00E2rejectedrejected[]
ISO8859-2U+0102U+0102U+0102 U+0104U+0102 U+02D8acceptedaccepted[kot]
ISO8859-15U+00C3U+00C3U+00A1 U+00C3U+00A2 U+00C3acceptedaccepted[kot]
KOI8-RU+0446U+0446U+0446 U+2551U+0446 U+2552acceptedaccepted[kot]
ISO8859-14U+FFFDU+FFFDU+003F U+FFFDU+003F U+FFFDacceptedaccepted[kot]
ISO8859-3, -6, -8load 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 Dictionary constructors: Hunspell spell checking, HunspellStemFilter, and HunspellStemFilterFactory, which builds the same Dictionary (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 example kote → kot), which then become index and query terms. For ISO8859-3, -6 and -8 (with these flags) the dictionary does not load at all, with UnmappableCharacterException: Input length = 1 and 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.

Minimal repro

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.

TestFlagUtf8With8BitSet.java (JUnit test)46 lines
/*
 * 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"));
  }
}
Command
# 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.

All files of this repro: repro/LFV-932/

More files, environment, full output48 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • hunspell(5) defines FLAG UTF-8 flags 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_flags and decode_flag (case FLAG_UNI) call u8_u16 on 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. SimpleFlagParsingStrategy says 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.testUtf8Flag covers FLAG UTF-8 only with the default charset, testFlagWithCrazyWhitespace only with UTF-8; flagutf8.aff has 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 long flags 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.

Suggested 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 test passes (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.

Reject the combination with a clear error

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.

Downstream note

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.

How it was found

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 getFlagParsingStrategy explained the split; an AI finder then wrote a 5-test JUnit (public API plus the package-private Stemmer), and independent AI tools reproduced, reviewed and judged it (see the checks).

Prior reports, history and checks

Prior reports

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).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr)2026-10-28part 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 searchesno report; closest #10747 (the feature itself), #6886 (precedent)
Open PRs2026-10-28the 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 lists2026-10-289 JQL searches (LUCENE, SOLR); list queries over dev@, java-user@, issues@, commits@, solr-user@, general@ and 16 Solr-list searchesno report
Web2026-10-28web queries ingested by the scripted prior-report searchno report; only verbatim copies of Lucene's source and C Hunspell references

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe 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 affectedISO8859-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 errorevery 8-bit SET, ISO8859-1 included: IllegalArgumentException "expected only one flag" (ISO8859-3: unmappable byte)
3.5.0, 3.6.2, 4.7.2 affectedISO8859-1, -2 and -15 merged; ISO8859-14 unsupported; ISO8859-3 load error
3.4.0 API absentno Hunspell module: the probe does not compile
main + fix B not affectedthe brief's test 8 of 8; 166 hunspell tests and the module's 1,917 tests, 0 failed

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateA 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 realOwn 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 realBuilt 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 noneThe 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 keepThe 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 reproducedThe 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.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,577 words

Finding record

LFV-932, snapshot of 2026-11-06

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 with checkFlags = 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.

Lucene and upstream sources

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) :1420
  • lucene/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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.