All findings · Lucene finding · LFV-895 · csvutil-parse-last-field-not-unquoted

CSVUtil.parse decodes every quoted field except the last one, so a quoted kuromoji part-of-speech keeps its quotes

CSVUtil.parse strips the CSV quotes from every field of a line except the last: "p,q","p,q","p,q" parses to p,q, p,q, "p,q". Kuromoji reads user dictionaries with it and the part-of-speech is the last field, so since 3.6.0 a quoted part-of-speech keeps its quotes and stop tags miss it.

Summary

Priority
Low, rank 519A quoted last field in the opt-in kuromoji user dictionary keeps its quotes but only corrupts the part-of-speech tag on rare input.
Impact
silent wrong results
Since
3.6.0 (2012, LUCENE-3305, the Kuromoji donation)introduced 2012-01-12 · over 14 years
Affected
3.6.0 to 10.6.0 and main
Status
documented
Found by
bounded-exhaustive check against an RFC 4180 reference parser

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 CSV line is split at commas outside quotes, and a quoted field loses its enclosing quotes. CSVUtil.parse does that for every field followed by a comma but forgets the last field of the line. Kuromoji's user dictionary puts the part-of-speech last.

Diagram of CSVUtil.parse on the line "p,q","p,q","p,q": each field that ends at a comma goes through unQuoteUnEscape (line 56) and comes out as p,q, but the last field is added as is (line 65) and comes out as "p,q" with its quotes. Below, the kuromoji user-dictionary entry "abcd","a b cd","foo1 foo2 foo3","bar": surface, segmentation and readings decode, the part-of-speech, the last field, stays "bar", so a stop filter for bar keeps all 3 tokens. The fix adds the unQuoteUnEscape call at line 65.
One CSV line, three copies of the same quoted value: the two fields followed by a comma lose their quotes, the last keeps them; then the same thing for a fully quoted kuromoji user-dictionary entry, whose part-of-speech comes out as "bar" and slips past the stop filter; and the one-line fix. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Write a kuromoji user-dictionary entry with every field quoted, as a CSV tool that quotes all fields would: the upstream test entry abcd,a b cd,foo1 foo2 foo3,bar becomes "abcd","a b cd","foo1 foo2 foo3","bar".
  2. You expect it to behave like the unquoted entry: abcd splits into a, b, cd with readings foo1, foo2, foo3 and part-of-speech bar, and a JapanesePartOfSpeechStopFilter with stop tag bar removes all 3 tokens.
  3. You get the right terms and readings, but all 3 tokens report the part-of-speech "bar", quotes included, and the stop filter removes 0 of 3. No error.
  4. The cause is one line of CSVUtil.parse: a field that ends at a comma goes through unQuoteUnEscape (CSVUtil.java:56), the last field is added as is (:65). The part-of-speech is the last of the 4 fields. On its own, parse("\"p,q\",\"p,q\",\"p,q\"") returns [p,q, p,q, "p,q"].

Who is affected

Kuromoji users whose custom user dictionary quotes the part-of-speech, because the tag contains a comma or because a CSV tool quoted every field, on every release since 3.6.0, and direct callers of CSVUtil.parse. Tags get literal quotes silently; exact-tag logic such as part-of-speech stop filters stops matching. Rare; the shipped system dictionary has no quotes.

Mechanism, terms and numbers564 words

CSVUtil (public, in the exported package org.apache.lucene.analysis.util of analysis-common) is Lucene's "Utility class for parsing CSV text". Its parse splits a line at commas outside quotes and decodes each field that ends at such a comma (enclosing quotes removed, "" turned into "), but adds the last field without decoding. The same quoted value therefore decodes in the first or a middle position and keeps its quotes in the last: parse("\"p,q\",\"p,q\",\"p,q\"") returns [p,q, p,q, "p,q"], and parse does not decode its own encoder's output (quoteEscape) when it is the last field.

Kuromoji's UserDictionary.open, used by JapaneseTokenizerFactory's userDictionary option, reads every line with parse and takes the part-of-speech from the fourth and last field. An entry written with every field quoted gives tokens whose part-of-speech is "bar" with the quotes, while surface, segmentation and readings decode; a JapanesePartOfSpeechStopFilter with stop tag bar then removes nothing. A part-of-speech that contains a comma has to be quoted, so it keeps its quotes too; the only way around it is an undocumented trick, a trailing comma (an empty fifth field, which UserDictionary ignores). The line dates from the 2012 Kuromoji donation; every release from 3.6.0 to 10.6.0 and main is affected. The fix is one line.

Terms

  • CSVUtil: "Utility class for parsing CSV text" (CSVUtil.java:23), a public final class in the exported package org.apache.lucene.analysis.util (analysis-common module-info.java:88), not marked internal. parse is "Parse CSV line", "@param line line containing csv-encoded data", "@return Array of values" (:35-40); quoteEscape is "Quote and escape input value for CSV" (:94) and wraps any value that holds a comma in quotes (:101-103).
  • Kuromoji user dictionary: a text file of lines surface,segmentation,readings,part-of-speech (layout comment UserDictionary.java:58; the test file userdict.txt), loaded by UserDictionary.open(Reader) or the userDictionary option of JapaneseTokenizerFactory (JapaneseTokenizerFactory.java:143-155). Its entries override the system dictionary for matching text.

The bug

  1. parse walks the line once and toggles insideQuote at every " (CSVUtil.java:46-52).
  2. At a comma outside quotes it decodes the field: value = unQuoteUnEscape(value); result.add(value); (:54-58). unQuoteUnEscape strips enclosing quotes when the field matches ^"([^"]+)"$ and turns "" into " (:75-92).
  3. After the loop it adds the rest of the line: result.add(sb.toString()); (:65). That is the last field, never decoded.
  4. UserDictionary.open calls CSVUtil.parse(line) for each line (UserDictionary.java:67) and stores String pos = values[3]; (:100), the last of the 4 fields, next to each segment's reading (:130). getPartOfSpeech returns it unchanged.
  5. JapanesePartOfSpeechStopFilter keeps a token unless stopTags.contains(pos) (:44), an exact match, so "bar" does not match bar.

The numbers

inputexpectedstock (main, 10.5.2, 9.12.3)with the fix
parse("\"p,q\",\"p,q\",\"p,q\"")[p,q, p,q, p,q][p,q, p,q, "p,q"][p,q, p,q, p,q]
parse("\"x\",\"bar\"")[x, bar][x, "bar"][x, bar]
parse("x," + quoteEscape("To be, or not to be ..."))[x, To be, or not to be ...]the value with its quotes[x, To be, or not to be ...]
control: parse(quoteEscape("To be, or not to be ...") + ",x")decodeddecodeddecoded
user dictionary "abcd","a b cd","foo1 foo2 foo3","bar", text abcda/foo1/bar, b/foo2/bar, cd/foo3/bara/foo1/"bar", b/foo2/"bar", cd/foo3/"bar"as expected
same entry, stop tag bar0 tokens kept3 tokens kept0 tokens kept
control: the entry unquotedbar; 0 kept with the stop tagsamesame
Exposure and likelihood355 words
  • Who is exposed. (a) Kuromoji users with a custom user dictionary (opt-in: JapaneseTokenizerFactory's userDictionary option, UserDictionary.open, or a JapaneseAnalyzer built with one) whose part-of-speech field is quoted. (b) Any direct caller of the public CSVUtil.parse whose data quotes the last field.
  • What the damage looks like. The part-of-speech tag carries literal quotes, with no error. Segmentation, readings and matching are right, so tokenization looks fine. Anything that compares tags exactly sees a different tag: JapanesePartOfSpeechStopFilter (also inside JapaneseAnalyzer, JapaneseAnalyzer.java:112) keeps tokens it should drop, and code reading PartOfSpeechAttribute gets "bar".
  • Only an undocumented workaround. A part-of-speech that contains a comma has to be quoted (unquoted, the comma splits the field), and as the last field it keeps its quotes ("tagA,tagB" comes back with its quotes). The one way around it is a trailing comma: the quoted part-of-speech is then followed by a comma, so parse decodes it, and UserDictionary ignores the empty fifth field (it reads only values[0..3] and checks no field count, UserDictionary.java:95-100). On stock main, abcd,a b cd,foo1 foo2 foo3,"tagA,tagB", gives the part-of-speech tagA,tagB, and the stop tag tagA,tagB removes all 3 tokens. Nothing documents this, and it works only because of the same position rule.
  • How likely. Rare: the upstream test dictionary leaves the last field unquoted, the system dictionary shipped in the kuromoji jar has no quote character in any field, and nobody has reported it since 2012.
  • Scale. One line reproduces it; assertions on and off behave the same; nothing depends on index size or concurrency.
  • Not affected. The kuromoji system dictionary (an enumeration of all 392,127 words on main finds no quote character in any field); nori's runtime user dictionary, which splits on whitespace. The kuromoji and nori TokenInfoDictionaryBuilder also call parse and would be affected only by a custom dictionary source that quotes its last field; kuromoji's writePosDict re-parses quoteEscape output whose last part is the inflection form, so it is wrong when that field holds a comma or a quote; UnknownDictionaryBuilder appends ,*,* before parsing, so the source's last field is decoded. None of these builder paths was run.

Minimal repro

The test shows that CSVUtil.parse unquotes every quoted field except the last one of the line: parse("\"x\",\"bar\"") decodes the first field but returns the last one raw.

TestCSVUtilQuotedLastField.java (JUnit test)27 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.util;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestCSVUtilQuotedLastField extends LuceneTestCase {

  public void testQuotedLastFieldIsUnquoted() {
    // expected [x, bar]; actual [x, "bar"]: the first field is unquoted, the last keeps its quotes
    assertArrayEquals(new String[] {"x", "bar"}, CSVUtil.parse("\"x\",\"bar\""));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-895/, 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.util.TestCSVUtilQuotedLastField
Expected vs actual
Expected [x, bar]; actual [x, "bar"] (the last field keeps its quotes), with -ea and -da.

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

More files, environment, full output50 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
    => arrays first differed at element [1]; expected:<[bar]> but was:<["bar"]>
       org.junit.internal.ComparisonCriteria.arrayEquals(ComparisonCriteria.java:78)
       org.junit.internal.ComparisonCriteria.arrayEquals(ComparisonCriteria.java:28)
       org.junit.Assert.internalArrayEquals(Assert.java:534)
       org.junit.Assert.assertArrayEquals(Assert.java:285)
       [...]
     Caused by: org.junit.ComparisonFailure: expected:<[bar]> but was:<["bar"]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.junit.internal.ExactComparisonCriteria.assertElementsEqual(ExactComparisonCriteria.java:8)
       org.junit.internal.ComparisonCriteria.arrayEquals(ComparisonCriteria.java:76)
       [...]

Why it looks unintended

  • parse already decodes every quoted field that a comma follows (CSVUtil.java:56); no CSV dialect decodes by position, and nothing documents or tests the exception.
  • The javadoc promises "csv-encoded data"; parse does not decode quoteEscape's own output as the last field.
  • A committer suggested the format is meant to be "CSV with '#' comments"; their example quotes the last field (LUCENE-7635).
The full argument306 words
  • Contract. "Utility class for parsing CSV text" (CSVUtil.java:23); parse: "@param line line containing csv-encoded data", "@return Array of values" (:35-40); quoteEscape: "Quote and escape input value for CSV" (:94), which wraps values with a comma in quotes. No javadoc literally promises RFC 4180 or parse(quoteEscape(v)) == v; the case rests on the class's own decoding of every other field.
  • Tests. TestCSVUtil tests only quoteEscape (3 tests, added by SOLR-9413). Every in-tree parse call in tests uses unquoted lines (kuromoji and nori TestUnknownDictionary), and the kuromoji userdict.txt has no quote characters. Nothing pins the quotes on the last field.
  • History. The line came with the 2012 Kuromoji donation and never changed: six later commits edited parse, none touched it, and no branch ever had the fix. The comparable one-line CSVUtil omission (quoteEscape discarding the result of String.replace) was reported (#6913 / LUCENE-5851) and fixed as a bug (SOLR-9413).
  • Committers on the format. In LUCENE-7635 (#8686, 2017) hossman wrote that "it seems like the intent of the UserDict format is to be \"CSV with '#' comments\"", with the example line "quoted#sharp",other,"quoted,stuff", whose last field is quoted. It was a tentative reading in a discussion about '#' comments and says nothing about how the last field is decoded. In LUCENE-8596 (#9642) msokolov called a user-dictionary parsing limitation "a real problem we should fix" and fixed it on main with a CHANGES note.
  • What is not claimed. Quote-only values (quoteEscape writes them without enclosing quotes, which is not valid RFC 4180) and empty quoted fields: in CSVUtil's dialect "" is quoteEscape's encoding of one quote, which RFC 4180 reads as an empty field. A quoted field that also holds a quote keeps its enclosing quotes in every position; that is the sibling defect LFV-889.
The devil's-advocate check182 words

The strongest "intended" case (rated moderate by the intended-lens review from another model family; also argued by the devil's-advocate verifier): CSVUtil began in 2012 as a narrow helper for MeCab dictionary lines, whose last field is never quoted in stock data; its javadocs are one line each and claim no RFC 4180; the raw append survived 14 years, a nori copy and two package moves without a report; the separate atilika/kuromoji project, by the original Kuromoji authors, has the same omission and its tests quote only non-last fields; and a change may break callers that rely on the raw last field.

Why it fails: the class itself decodes quoted fields in every other position, so a narrow dialect cannot explain a rule that depends only on position. Age and the inherited copy show the case was never exercised, not that it was meant: nothing documents, tests or comments on keeping the quotes, and no Lucene code relies on the raw last field. Compatibility decides how a fix ships (main, with a CHANGES entry, as LUCENE-8596 did), not whether this is a bug.

Suggested fix

Recommended Decode the last field with unQuoteUnEscape

Decode the last field like every other one: result.add(unQuoteUnEscape(sb.toString())); at CSVUtil.java:65. The repro, TestCSVUtil, the kuromoji and nori suites and all 1,912 analysis-common tests pass. It changes parsed values of existing lines whose last field is quoted, so target main with a CHANGES entry.

-    result.add(sb.toString());
+    result.add(unQuoteUnEscape(sb.toString()));
All options and trade-offs3 options ยท 864 words

Option A is the bug's own fix. Option B adds LFV-889's recommended parse-only fix for the separate unquote defect (a quoted field that also holds a quote, in any position); it was checked with the standalone runner only. Do not use the regex ^"(.*)"$ for LFV-889: it breaks the one-quote encoding "" that quoteEscape writes. Add the new tests (or their cases in TestCSVUtil and kuromoji's TestUserDictionary) with whichever is chosen.

Decode the last field with unQuoteUnEscape (validated by the checks) (recommended)

Line 65 of CSVUtil.java gets the call that line 56 already makes for every comma-terminated field (+1/-1). The finder's fix.

-    result.add(sb.toString());
+    result.add(unQuoteUnEscape(sb.toString()));

Trade-offs

  • The smallest change, and parse no longer depends on position.
  • It changes the parsed value of lines whose last field is quoted (the quotes go) or holds a doubled quote: an unquoted x,a""b now gives a"b, as a""b,x already gives in the first position. Kuromoji part-of-speech tags of such entries change; indexed terms change only where the tag decides something (a part-of-speech stop filter, or code that turns tags into terms), and those fields need a reindex. Main with a CHANGES entry, as LUCENE-8596 did; a backport is debatable.
  • With A, x,"" gives [x, "], the one quote that "",x already gives in the first position: in CSVUtil's dialect "" is how quoteEscape writes one quote. A leaves the sibling LFV-889: a quoted field that also holds a quote keeps its enclosing quotes in every position (x,"say ""hi""" gives [x, "say "hi""]), and LFV-889's tests still fail 4 of 9. In the finder's bounded model with A, the round-trip law fails only for values with both a comma and a quote (10,110 of 35,140 field lists, down from 26,700).

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, JDK 25: both new tests fail on stock (3 of 3 and 2 of 3, assertions on and off) and pass with the fix; org.apache.lucene.analysis.util.* (9 suites, 35 tests, TestCSVUtil included) plus the whole kuromoji (31 suites, 198 tests) and nori (13 suites, 87 tests) suites: 320 tests, 0 failures, 11 skipped, with assertions on (seed 5EED0895) and off (seed 5EED0896); the whole :lucene:analysis:common:test: 340 suites, 1,912 tests, 0 failures, 18 skipped; checkGoogleJavaFormat passes for both modules.
  • Standalone runner with the fix shadowed: 3 of 3 and 3 of 3 pass with -ea and -da; earlier the finder's, the independent reproducer's, the cross-model reviewer's and the devil's advocate's tests passed with the same line. An independent fact check repeated the 320-test set with new seeds in both modes and the format check, with the same result.

Also fix LFV-889 with its parse-only change (if filed together) (validated by the checks)

Option A plus LFV-889's recommended parse-only change to unQuoteUnEscape (its independent reproducer's sketch): strip the enclosing quotes only when the field ends with a quote and begins with an odd run of quotes, then unescape. quoteEscape doubles every quote, so an unenclosed value has only even runs and "" stays one quote. Then x,"say ""hi""" gives [x, say "hi"], while x,"" and "",x keep the one-quote reading ([x, "], [", x]).

     // Unquote
     if (result.indexOf('\"') >= 0) {
-      Matcher m = QUOTE_REPLACE_PATTERN.matcher(original);
-      if (m.matches()) {
-        result = m.group(1);
+      // quoteEscape doubles every quote, so an unenclosed value has only even-length quote runs;
+      // an enclosed value starts with an odd-length run (the enclosing quote plus doubled quotes).
+      int lead = 0;
+      while (lead < result.length() && result.charAt(lead) == QUOTE) {
+        lead++;
+      }
+      if (result.length() >= 2
+          && (lead & 1) == 1
+          && result.charAt(result.length() - 1) == QUOTE) {
+        result = result.substring(1, result.length() - 1);
       }
...
-    result.add(sb.toString());
+    result.add(unQuoteUnEscape(sb.toString()));

Trade-offs

It changes the output for quoted fields that hold a quote, in every position, so it needs its own CHANGES note and is LFV-889's decision too. The sketch leaves QUOTE_REPLACE_PATTERN and the Matcher import unused. Do not use the finder's first sketch for LFV-889, the regex ^"(.*)"$, instead: it reads "" as an empty field, so "",x gives [, x] instead of [", x], and the round-trip law then fails for 3,578 field lists, 2,316 of them in non-last fields that pass on stock; LFV-889's judge says it must not be proposed.

What the checks validated

Standalone runner on a9779ef3c193 with the combined source shadowed (not gradle, not the module suites, not format-checked): the brief's tests pass 3 of 3 and 3 of 3 with -ea and -da; LFV-889's own tests pass 9 of 9 (4 of 9 fail on stock and with A alone); the finder's bounded model finds 0 of 35,140 round-trip mismatches and 1,713 of 18,304 RFC 4180 reference mismatches, all of the quoted-empty or escaped-quote shape, where RFC 4180 reads "" differently from CSVUtil's dialect.

Not recommended: document that the last field is returned raw

Say so in the parse javadoc. Not run.

Trade-offs

Changes no output, but documents a position-dependent rule that no CSV writer produces, leaves a part-of-speech with a comma decodable only through the trailing-comma trick, and contradicts quoteEscape's own output.

Downstream note

Search servers and applications that let operators upload a kuromoji user dictionary inherit the wrong tags; a CSV export that quotes every field is the likely trigger.

How it was found

A bounded-exhaustive comparison of the real CSVUtil with an RFC 4180 reference parser (all 87,381 lines of up to 8 symbols) and with its own round-trip law (35,140 field lists) showed quoted fields decoded before a comma but returned raw at the end of the line. Public-API tests then reproduced it.

The method in full264 words

Method: bounded-exhaustive translation validation on the stock main jars (the study's "semi-formal" tier). A hunt unit, a sweep of public analysis-common APIs that no earlier hunt had named, 2026-10-28.

  • Reference check: all 87,381 lines of up to 8 symbols over {a, comma, quote, U+10400} (18,304 valid under RFC 4180) were parsed by the stock CSVUtil and by a reference parser. All 5,811 mismatches are quoted fields. In the last field the stock code returns the field raw: plain 2,814 (this bug alone), empty 449 and escaped quote 962. The empty and escaped-quote shapes also mismatch in non-last fields (1,122 and 464), where RFC 4180 and CSVUtil's dialect read "" differently or the sibling LFV-889 applies, so only the plain count is this bug's.
  • Round-trip check: parse(join(quoteEscape(f_i))) == fields over 35,140 field lists. The classes "last field with a comma" (8,295) and "last field with a comma and a quote" (4,206, which also needs LFV-889's fix) come from this bug; the last-field quote-only class (8,295) comes from quoteEscape's unenclosed encoding of quote-only values and is not claimed (the cross-model reviewer's correction).
  • Re-run for this doc with the fixes shadowed: option A alone leaves only the round-trip failures of values with both a comma and a quote (10,110, LFV-889) and reference mismatches of the same two shapes in every position (2,997); A plus LFV-889's parse-only fix leaves 0 round-trip failures and 1,713 reference mismatches, all where RFC 4180 reads "" differently from CSVUtil's dialect.
  • Then an AI finder wrote the public-API JUnits, and independent AI tools reproduced, reviewed, judged and audited it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (search of 2026-10-28): 147 logged queries over GitHub issues, PRs and commits, downstream projects, mailing lists, JIRA and the web, plus all 410 open PRs. Related items concern other CSVUtil and user-dictionary defects.

Searches4 searches

Nothing reports that CSVUtil.parse leaves a quoted last field quoted, or that a quoted kuromoji part-of-speech keeps its quotes. Upstream main c5ab3e57b9ba, branch_10x and branch_10_6 still have the line; the last commit to CSVUtil.java is #16056 (2026-05-14).

Related, not the same bug: #8686 (LUCENE-7635; a 2017 comment uses a quoted last field as an example without noticing the defect), #9642 (LUCENE-8596, '#' in user dictionaries), #6569 / #6913 / SOLR-9413 (quoteEscape ignored the String.replace result; fixed 2016), #4770 (LUCENE-3696, speed), #12389 / #12390 / #12773 / #12774 (the 2023 class move). The separate atilika/kuromoji project has the same omission in its own parser and no report of it either. The sibling regex defect LFV-889 has a partial overlap there (atilika/kuromoji fixed its copy of the regex in 2015); this bug has none.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, the old lucene-solr repository), commits2026-10-28two runs of a scripted prior-report search (16 issue/PR and 3 commit queries each: class and method names, unQuoteUnEscape, user dictionary and quotes, part-of-speech and commas, last field) and 31 manual searchesno report; the one high-overlap hit, #8686, is a different defect
Open PRs2026-10-28the changed files of all 410 open apache/lucene PRs; refreshed for the 37 updated since 2026-10-26only #16911 (decommissions branch_10x, deleting every file) lists CSVUtil.java or kuromoji UserDictionary.java
Mailing lists and JIRA2026-10-2820 list queries per tool run plus 25 manual ones over dev@, java-user@, issues@ and Solr lists; JIRA LUCENE and SOLRno report
Downstream repositories and the web2026-10-28Solr, Elasticsearch, Lucene.NET and atilika/kuromoji on GitHub, all of GitHub for "CSVUtil.parse"; 18 web searches, one in Japaneseno report; an Elasticsearch PR that calls CSVUtil.parse on user-dictionary rules does not discuss quoting

History

Present since CSVUtil was added with the Kuromoji donation (cd372bdc8383, LUCENE-3305, 2012-01-12), first released in 3.6.0; the line never changed through two package moves. It reproduces on 15 releases from 3.6.0 to 10.5.2, 10.6.0 (branch_10_6 4ae946e5a9b9) and main; 3.5.0 has no CSVUtil.

Versions tested6 rows

Introduced by cd372bdc8383 "LUCENE-3305: add Kuromoji Japanese morphological analyzer" (svn trunk r1230748, 2012-01-12, committed by rmuir from the Kuromoji code donation, migrated as apache/lucene#4378; no PR in the svn era), backported to branch_3x as 8c9485fb6642 (2012-01-14). Six later commits edited parse (LUCENE-3696, LUCENE-4362, LUCENE-5512, LUCENE-9570, #12390, #16056); none touched the last-field line. The class lived in kuromoji (org.apache.lucene.analysis.ja.util) for 3.6.0-9.8.0, had a nori copy (org.apache.lucene.analysis.ko.util, dictionary builders only) in 7.4.0-9.8.0 that fails the same way, and moved to analysis-common (org.apache.lucene.analysis.util) in 9.9.0 (#12390 / #12774). All 126 release tags 3.6.0-10.5.2 in the history clone carry the unfixed line.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's tests fail 3 of 3 and 2 of 3 (gradle and standalone, assertions on and off)
10.5.2, 9.12.3 affectedthe brief's tests fail 3 of 3 and 2 of 3 with assertions on (10.5.2 also with assertions off, in the fact check); the finder's tests fail too
branch_10_6 4ae946e5a9b9 affectedthe CSV probe and the finder's CSV test fail (no kuromoji jar in that jar set)
3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.4.0, 7.7.3, 8.11.4, 9.0.0, 9.8.0, 9.9.0, 10.0.0 affectedthe version-independent CSV probe fails (4 of 4 checks keep the quotes, controls pass); the kuromoji probe fails on all but 3.6.2 and 7.4.0 (not run); 7.4.0-9.8.0 also the nori copy
3.5.0 API absentno CSVUtil and no kuromoji module
main + fix fixedall tests and probes pass

Independent checks

An independent reproducer, a devil's advocate, an adjudication and reviews by another model family (which ran their own tests) all found it real; the adjudication confirmed it with high confidence. A registry re-run, an audit, this doc's own runs and an independent fact check reproduced it with new seeds.

Checks and verdicts451 words

Every check wrote or ran its own test against the stock jars, with assertions on and off unless noted.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-API JUnits fail on main (-ea, -da), 10.5.2 and 9.12.3: CSV 2 of 2, kuromoji 1 of 1; on branch_10_6 the CSV test fails 2 of 2 (the kuromoji test was not run there). The CSV test still fails 2 of 2 with the sibling's regex sketch applied; both pass with the one-line fix.
cross-model review, refute lens real (high)An independent reviewer from another model family (OpenAI GPT-5.6) ran its own JUnit on main ea57360912ed (analysis sources identical to a9779ef3c193) and 10.5.2; root cause and fix agree; it corrected the round-trip claim (quote-only values dropped).
Independent reproducer realOwn JUnits fail 4 of 5 (CSV) and 1 of 2 (kuromoji) on main, 10.5.2 and 9.12.3; the controls pass; with the fix its tests, upstream TestCSVUtil and kuromoji TestUnknownDictionary pass.
Cross-model review, intended lens real (high)OpenAI GPT-5.6: the intended-behaviour argument is moderate and rejected; its own tests fail with assertions on, on main (CSV and kuromoji), 10.5.2 and 9.12.3 (CSV), and separate re-runs fail on main with assertions off too; they pass with the fix.
Devil's advocate realOwn 6-test JUnit fails 3 of 6 on main and 10.5.2, including the stop filter removing nothing; the controls pass; 6 of 6 with the fix; found the LUCENE-7635 comment and the external parser.
adjudication confirmed (high)Re-ran the independent reproduction's tests: 4 of 5 and 1 of 2 fail, 5 of 5 pass with the fix.
security assessment not security-sensitiveOnly dictionary or configuration text reaches the parser, which SECURITY.md treats as trusted; the effect is a wrong string value.
registry re-run and audit keepThe finder's tests fail again with new seeds (-ea and -da); an own probe shows the same value decoded first and middle, raw last; all pass with the fix.
this doc's runs reproduced; fix validatedNew tests through gradle and the standalone runner on main, 10.5.2 and 9.12.3; the fix validated against the module suites.
fact checks of this doc reproduced; wording errors fixedIndependent fact checks (one by another model family, OpenAI GPT-5.6) re-read the claims against the code. One re-ran the doc's gradle command (assertions on and off, plus a new seed) and the standalone runner with new seeds on main, 10.5.2 and 9.12.3: same failures; option A passed the 320-test set again. They found the trailing-comma workaround and a wrong option B (the regex that breaks the one-quote encoding); both are fixed in this version.

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,403 words

Finding record

LFV-895, snapshot of 2026-11-06

Registry title
CSVUtil.parse decodes every quoted field except the last one of the line, so a quoted last field (for example the part-of-speech of a kuromoji user-dictionary entry) keeps its quote characters
Area
analysis-common / util CSVUtil (CSV line parsing for the kuromoji user dictionary and the kuromoji/nori dictionary builders)
Priority
Low, rank 519: A quoted last field in the opt-in kuromoji user dictionary keeps its quotes but only corrupts the part-of-speech tag on rare input.
Impact class
silent wrong results
Impact note
Hits direct callers of the public CSVUtil.parse (exported package org.apache.lucene.analysis.util) and kuromoji users whose user dictionary (UserDictionary.open, or the JapaneseTokenizerFactory userDictionary resource) quotes the last, part-of-speech field, for example because the part-of-speech contains a comma (which can only be written quoted) or because a CSV tool quoted every field: with otherwise default settings, -ea and -da, and no scale, every token of such an entry reports the part-of-speech with literal quote characters ("bar" with the quotes instead of bar) and no error, so exact-tag consumers such as JapanesePartOfSpeechStopFilter do not match it (the intended-behaviour check ran this); segmentation and readings are decoded correctly, the shipped dictionaries do not quote the last field, and nori's runtime UserDictionary does not use CSVUtil.
Summary
You parse a CSV line whose last field is quoted, for example CSVUtil.parse("x," + CSVUtil.quoteEscape("To be, or not to be")), or load a kuromoji user dictionary written with every field quoted, and expect the quoted value to be decoded the same way as in any other position, as parse does for every field followed by a comma. Instead the last field comes back raw, enclosing quotes and doubled inner quotes included: parse("\"a,b\",\"c,d\"") returns [a,b, "c,d"], and the dictionary line "abcd","a b cd","foo1 foo2 foo3","bar" yields tokens whose part-of-speech is "bar" with the quotes. The cause is one line: parse passes each comma-terminated field through unQuoteUnEscape (CSVUtil.java:56) but adds the final field as sb.toString() (:65).
Introduced
cd372bdc8383 (LUCENE-3305 'add Kuromoji Japanese morphological analyzer', committed by rmuir from the Kuromoji code donation, migrated as apache/lucene#4378; svn trunk r1230748, svn era with no GitHub PR; branch_3x backport 8c9485fb6642, r1231536; first in the repository on the svn feature branch lucene3305 as bee8224c049c, which is not an ancestor of main) [read: git blame -w -M -C -C -C on main attributes CSVUtil.java:65 to cd372bdc8383; git log -L on parse lists six later changes (354a3be78f3 LUCENE-3696, ded01621a42 LUCENE-4362, 3b67b174932 LUCENE-5512, 7f8b7ffbcad LUCENE-9570, 30db2170484 #12390, dbf0d1426f1 #16056), none of which touches the last-field append; git log --all -G'unQuoteUnEscape\(sb' finds no branch that ever had the fix; exec: 3.5.0 has no CSVUtil, 3.6.0 fails] (2012-01-12 (trunk commit cd372bdc8383, author date = commit date, svn era); 2012-01-14 (branch_3x backport 8c9485fb6642); 2012-01-03 (feature branch bee8224c049c))
First affected version
3.6.0 [exec] (first release with CSVUtil, then org.apache.lucene.analysis.ja.util.CSVUtil in the kuromoji module; 3.5.0 [exec] has no CSVUtil and no kuromoji module, so there is no earlier unaffected release; on the trunk line 4.0.0-alpha, 4.0.0 executed)
Affected versions
every release from 3.6.0 to 10.5.2, branch_10_6 4ae946e5a9b9 (10.6.0, released 2026-10-29) and main a9779ef3c193; not fixed upstream. [read] all 126 release tags 3.6.0-10.5.2 in the full-history clone carry the unfixed line in every CSVUtil copy (8.9.0-8.11.4 are not tagged there; 8.11.4 was executed from release jars); upstream main c5ab3e57b9ba, branch_10x and branch_10_6 still have it (the last CSVUtil.java commit is #16056, author date = commit date 2026-05-14). [exec] the follow-up's portable main() probes (CSVUtil: 4 of 4 bug checks keep the quotes on the last field, 2 controls pass; kuromoji: an all-quoted user-dictionary line gives the part-of-speech "bar" with quotes on every token, the unquoted control gives bar) fail on 3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.4.0, 7.7.3, 8.11.4, 9.0.0, 9.8.0, 9.9.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 (CSVUtil only) and main, and pass with the fix shadowed on main; from 7.4.0 to 9.8.0 the nori copy org.apache.lucene.analysis.ko.util.CSVUtil (used only by nori's dictionary builders) fails its 3 checks too. The finder's JUnits fail on main (-ea and -da), branch_10_6, 10.5.2, 10.0.0, 9.12.3 and 9.9.0 (they do not compile on 8.11.4, which predates the analysis.util package and the tests.* framework); the independent reproduction's fail on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's on main (-ea and -da) and 10.5.2. The class lived in kuromoji (ja.util) for 3.6.0-9.8.0 and in analysis-common (org.apache.lucene.analysis.util) from 9.9.0 (#12390/#12774), each move carrying the line unchanged.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (the prior-report record verdict new, searched 2026-10-28, read-only; refreshed in the registration check): two scripted prior-report searches (each 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list and 4 JIRA queries plus web; 370 and 61 distinct hits, 1 and 0 rated high by keyword overlap, the high one #8686, read in full, a different defect) and 45 manual queries (147 in all), plus the finder's run (44 queries, 3,517 hits) and the independent reproduction's, the intended-behaviour check's and the adjudication's searches. No issue, PR, review comment, commit message, mailing-list post, JIRA entry, downstream report or web page says that CSVUtil.parse leaves a quoted last field quoted, or that a quoted kuromoji user-dictionary part-of-speech keeps its quotes. Closest items, none the same bug: #8686 (LUCENE-7635, '#' in a user dictionary; hossman's 2017 comment uses a line whose last field is quoted as an example and does not notice the defect), #9642 (LUCENE-8596, '#' handling fix, main only), #6569 (LUCENE-5506, open) and #6913 (LUCENE-5851) with SOLR-9413 (quoteEscape ignored the String.replace result; fixed 2016), #4770 (LUCENE-3696, build speed), #12389/#12390/#12773/#12774 (the 2023 class move). The predecessor project atilika/kuromoji's DictionaryEntryLineParser.parseLine still appends its last field raw (the same omission, not reported there either). Not fixed: CSVUtil.java:65 is unchanged on upstream main c5ab3e57b9ba (head, author date 2026-10-28), branch_10x and branch_10_6; the last commits to CSVUtil.java and to kuromoji UserDictionary.java are #16056 (dbf0d1426f13, author date = commit date 2026-05-14). Of all 410 open apache/lucene PRs only #16911 (decommission of branch_10x, which deletes the whole tree) lists CSVUtil.java; refresh: the file lists of all 37 open PRs updated since 2026-10-26 (3,233 file entries) show only #16911 touching CSVUtil.java, TestCSVUtil.java or kuromoji UserDictionary.java (all three 'removed'), and no apache/lucene issue or PR created since 2026-10-26 mentions kuromoji or CSV. Gaps: GitHub search returns at most 50 results per query; private reports are not searchable.
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 tests); version-independent main() probes on 3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.4.0, 7.7.3, 8.11.4, 9.0.0, 9.8.0, 9.9.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 4ae946e5a9b9 and main (follow-up). The fix was validated with gradle on a9779ef3c193. Upstream main c5ab3e57b9ba has the same CSVUtil.java (read, not executed).

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/util/CSVUtil.java:23 (class javadoc), :29 (QUOTE_REPLACE_PATTERN), :35-40 (parse javadoc), :54-57 (comma-terminated fields unquoted), :65 (last field added raw), :75-92 (unQuoteUnEscape), :94-105 (quoteEscape)
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/UserDictionary.java:52-75 (open), :58 (layout comment 'text, segmentation, readings, POS'), :67 (CSVUtil.parse per line), :100 (pos = values[3])
  • lucene/analysis/common/src/java/module-info.java:88 (exports org.apache.lucene.analysis.util)
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/util/CSVUtil.java:23, (at a9779ef3c193) :29, :35-40, :46-52, :54-58, :65, :75-92, :94-105
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/UserDictionary.java:52-76 (at a9779ef3c193) (open; :58 layout comment; :67 parse), :100 (pos = values[3]), :130
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/UserMorphData.java:58-59, (at a9779ef3c193) :91-110
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapanesePartOfSpeechStopFilter.java:36-45; (at a9779ef3c193) JapaneseTokenizerFactory.java:36-50, :143-155
  • lucene/analysis/common/src/java/module-info.java:88 (at a9779ef3c193)
  • lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/userdict.txt (at a9779ef3c193) ('Silly entry')
Who is affected
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseTokenizerFactory.java:143-155; (at a9779ef3c193) JapaneseAnalyzer.java:56-57, :108-116
  • lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/dict/UserDictionary.java:95-100; (at a9779ef3c193) ja/dict/TokenInfoDictionaryBuilder.java:73; ja/dict/UnknownDictionaryBuilder.java:66; ja/dict/TokenInfoDictionaryEntryWriter.java:76-84, :226; lucene/analysis/nori/src/java/org/apache/lucene/analysis/ko/dict/TokenInfoDictionaryBuilder.java:68; ko/dict/UnknownDictionaryBuilder.java:66; ko/dict/TokenInfoDictionaryEntryWriter.java:181-186
Why it looks unintended
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/util/CSVUtil.java:23, (at a9779ef3c193) :35-40, :54-58, :65, :94-105
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/util/TestCSVUtil.java:25-50; (at a9779ef3c193) lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/dict/TestUnknownDictionary.java:41-55; lucene/CHANGES.txt:8993
  • GitHub, read-only (2026-10-28): apache/lucene issue #8686 (hossman's comment, LUCENE-7635), issue #9642 (msokolov's comments, LUCENE-8596)
Suggested fix
  • GitHub, read-only (2026-10-28): apache/lucene issue #9642 (msokolov: fix on main with CHANGES, no 8.x backport)

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