Skip to content

Commit 87ab54b

Browse files
authored
TIKA-4784 -- improve utf16 handling in id3 (#2943)
1 parent 200c9c1 commit 87ab54b

6 files changed

Lines changed: 306 additions & 18 deletions

File tree

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/ID3v22Handler.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,13 @@ public ID3v22Handler(ID3v2Frame frame) throws IOException, SAXException, TikaExc
7171
case "TCR":
7272
copyright = getTagString(tag.data, 0, tag.data.length);
7373
break;
74-
case "COM":
75-
comments.add(getComment(tag.data, 0, tag.data.length));
74+
case "COM": {
75+
ID3Comment comment = getComment(tag.data, 0, tag.data.length);
76+
if (comment != null) {
77+
comments.add(comment);
78+
}
7679
break;
80+
}
7781
case "TRK":
7882
trackNumber = getTagString(tag.data, 0, tag.data.length);
7983
break;

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/ID3v23Handler.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,13 @@ public ID3v23Handler(ID3v2Frame frame) throws IOException, SAXException, TikaExc
7272
case "TCOP":
7373
copyright = getTagString(tag.data, 0, tag.data.length);
7474
break;
75-
case "COMM":
76-
comments.add(getComment(tag.data, 0, tag.data.length));
75+
case "COMM": {
76+
ID3Comment comment = getComment(tag.data, 0, tag.data.length);
77+
if (comment != null) {
78+
comments.add(comment);
79+
}
7780
break;
81+
}
7882
case "TRCK":
7983
trackNumber = getTagString(tag.data, 0, tag.data.length);
8084
break;

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/ID3v24Handler.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,13 @@ public ID3v24Handler(ID3v2Frame frame) throws IOException, SAXException, TikaExc
7878
case "TCOP":
7979
copyright = getTagString(tag.data, 0, tag.data.length);
8080
break;
81-
case "COMM":
82-
comments.add(getComment(tag.data, 0, tag.data.length));
81+
case "COMM": {
82+
ID3Comment comment = getComment(tag.data, 0, tag.data.length);
83+
if (comment != null) {
84+
comments.add(comment);
85+
}
8386
break;
87+
}
8488
case "TRCK":
8589
trackNumber = getTagString(tag.data, 0, tag.data.length);
8690
break;

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/ID3v2Frame.java

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -230,25 +230,73 @@ protected static String getTagString(byte[] data, int offset, int length) {
230230
// (return empty string), because new String(..)
231231
// gives different results on different JVMs
232232
if (encoding.encoding.equals("UTF-16") && actualLength == 2 &&
233-
((data[offset] == (byte) 0xff && data[offset + 1] == (byte) 0xfe) ||
234-
(data[offset] == (byte) 0xfe && data[offset + 1] == (byte) 0xff))) {
233+
hasBOM(data, offset, actualLength)) {
235234
return "";
236235
}
237236

238237
try {
239238
// Build the base string
240-
return new String(data, offset, actualLength, encoding.encoding);
239+
return decodeText(data, offset, actualLength, encoding);
241240
} catch (UnsupportedEncodingException e) {
242241
throw new RuntimeException("Core encoding " + encoding.encoding + " is not available",
243242
e);
244243
}
245244
}
246245

247246
/**
248-
* Builds up the ID3 comment, by parsing and extracting
249-
* the comment string parts from the given data.
247+
* Decodes text in the frame's declared encoding. $01 is UTF-16 with a BOM; if a tagger omits
248+
* it, Java assumes big-endian and turns little-endian text into mojibake ('T' 0x54 0x00 ->
249+
* U+5400), so recover the byte order from the bytes.
250+
*/
251+
private static String decodeText(byte[] data, int offset, int length, TextEncoding encoding)
252+
throws UnsupportedEncodingException {
253+
String charset = encoding.encoding;
254+
if ("UTF-16".equals(charset) && !hasBOM(data, offset, length)) {
255+
charset = guessUTF16ByteOrder(data, offset, length);
256+
}
257+
return new String(data, offset, length, charset);
258+
}
259+
260+
private static boolean hasBOM(byte[] data, int offset, int length) {
261+
if (length < 2) {
262+
return false;
263+
}
264+
int first = data[offset] & 0xff;
265+
int second = data[offset + 1] & 0xff;
266+
return (first == 0xff && second == 0xfe) || (first == 0xfe && second == 0xff);
267+
}
268+
269+
/**
270+
* Recovers the byte order of BOM-less UTF-16, only reached when a $01 frame omits its
271+
* mandatory BOM (a malformed tagger). Chars below U+0100, which dominate ID3 tags, carry one
272+
* NUL per code unit whose column reveals the order. Chars above (eg CJK) carry no NUL and no
273+
* signal; pure-CJK text then falls back to big-endian - Java's own BOM-less default, so this
274+
* is never worse than the prior unconditional decode.
275+
*/
276+
private static String guessUTF16ByteOrder(byte[] data, int offset, int length) {
277+
int bigEndian = 0;
278+
int littleEndian = 0;
279+
for (int i = 0; i + 1 < length; i += 2) {
280+
if (data[offset + i] == 0) {
281+
bigEndian++;
282+
}
283+
if (data[offset + i + 1] == 0) {
284+
littleEndian++;
285+
}
286+
}
287+
return littleEndian > bigEndian ? "UTF-16LE" : "UTF-16BE";
288+
}
289+
290+
/**
291+
* Parses the comment parts from the given data, or null if the frame is too short or
292+
* malformed to hold a comment.
250293
*/
251294
protected static ID3Comment getComment(byte[] data, int offset, int length) {
295+
// encoding flag + 3-byte language
296+
if (length < 4) {
297+
return null;
298+
}
299+
252300
// Comments must have an encoding
253301
int encodingFlag = data[offset];
254302
if (encodingFlag >= 0 && encodingFlag < encodings.length) {
@@ -264,36 +312,38 @@ protected static ID3Comment getComment(byte[] data, int offset, int length) {
264312
String lang = getString(data, offset + 1, 3);
265313

266314
// After that we have [Desc]\0(\0)[Text]
315+
int end = offset + length;
267316
int descStart = offset + 4;
268317
int textStart = -1;
269318
String description = null;
270319
String text = null;
271320

272321
// Find where the description ends
273322
try {
274-
for (int i = descStart; i < offset + length; i++) {
275-
if (encoding.doubleByte && data[i] == 0 && data[i + 1] == 0) {
323+
for (int i = descStart; i < end; i++) {
324+
// a double byte terminator needs both bytes present
325+
if (encoding.doubleByte && i + 1 < end && data[i] == 0 && data[i + 1] == 0) {
276326
// Handle LE vs BE on low byte text
277-
if (i + 2 < offset + length && data[i + 1] == 0 && data[i + 2] == 0) {
327+
if (i + 2 < end && data[i + 2] == 0) {
278328
i++;
279329
}
280330
textStart = i + 2;
281-
description = new String(data, descStart, i - descStart, encoding.encoding);
331+
description = decodeText(data, descStart, i - descStart, encoding);
282332
break;
283333
}
284334
if (!encoding.doubleByte && data[i] == 0) {
285335
textStart = i + 1;
286-
description = new String(data, descStart, i - descStart, encoding.encoding);
336+
description = decodeText(data, descStart, i - descStart, encoding);
287337
break;
288338
}
289339
}
290340

291341
// Did we find the end?
292342
if (textStart > -1) {
293-
text = new String(data, textStart, offset + length - textStart, encoding.encoding);
343+
text = decodeText(data, textStart, end - textStart, encoding);
294344
} else {
295345
// Assume everything is the text
296-
text = new String(data, descStart, offset + length - descStart, encoding.encoding);
346+
text = decodeText(data, descStart, end - descStart, encoding);
297347
}
298348

299349
// Return
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.parser.mp3;
18+
19+
import static java.nio.charset.StandardCharsets.ISO_8859_1;
20+
import static java.nio.charset.StandardCharsets.UTF_16BE;
21+
import static java.nio.charset.StandardCharsets.UTF_16LE;
22+
import static java.nio.charset.StandardCharsets.UTF_8;
23+
import static org.junit.jupiter.api.Assertions.assertEquals;
24+
import static org.junit.jupiter.api.Assertions.assertNull;
25+
26+
import java.io.ByteArrayOutputStream;
27+
import java.io.IOException;
28+
import java.nio.charset.Charset;
29+
30+
import org.junit.jupiter.api.Test;
31+
32+
import org.apache.tika.parser.mp3.ID3Tags.ID3Comment;
33+
34+
/**
35+
* Tests the shared ID3v2 text decoding used by every ID3v2.2/2.3/2.4 text frame.
36+
*/
37+
public class ID3v2FrameTest {
38+
39+
private static final byte ISO_8859_1_FLAG = 0;
40+
private static final byte UTF_16_BOM_FLAG = 1;
41+
private static final byte UTF_16BE_FLAG = 2;
42+
private static final byte UTF_8_FLAG = 3;
43+
44+
private static final byte[] BOM_LE = {(byte) 0xff, (byte) 0xfe};
45+
private static final byte[] BOM_BE = {(byte) 0xfe, (byte) 0xff};
46+
47+
// LATIN has a NUL per char in UTF-16 (a byte-order signal); CJK does not
48+
private static final String LATIN = "Test Copyright";
49+
private static final String CJK = "日本語";
50+
51+
private static byte[] frame(byte encodingFlag, byte[]... parts) {
52+
ByteArrayOutputStream out = new ByteArrayOutputStream();
53+
out.write(encodingFlag);
54+
for (byte[] part : parts) {
55+
out.write(part, 0, part.length);
56+
}
57+
return out.toByteArray();
58+
}
59+
60+
private static byte[] bytes(String text, Charset charset) {
61+
return text.getBytes(charset);
62+
}
63+
64+
private static String tagString(byte[] data) {
65+
return ID3v2Frame.getTagString(data, 0, data.length);
66+
}
67+
68+
@Test
69+
public void testSingleByteEncodings() {
70+
assertEquals(LATIN, tagString(frame(ISO_8859_1_FLAG, bytes(LATIN, ISO_8859_1))));
71+
assertEquals(CJK, tagString(frame(UTF_8_FLAG, bytes(CJK, UTF_8))));
72+
}
73+
74+
@Test
75+
public void testUTF16WithBOM() {
76+
assertEquals(LATIN, tagString(frame(UTF_16_BOM_FLAG, BOM_LE, bytes(LATIN, UTF_16LE))));
77+
assertEquals(LATIN, tagString(frame(UTF_16_BOM_FLAG, BOM_BE, bytes(LATIN, UTF_16BE))));
78+
assertEquals(CJK, tagString(frame(UTF_16_BOM_FLAG, BOM_LE, bytes(CJK, UTF_16LE))));
79+
assertEquals(CJK, tagString(frame(UTF_16_BOM_FLAG, BOM_BE, bytes(CJK, UTF_16BE))));
80+
}
81+
82+
@Test
83+
public void testUTF16BEWithoutBOMFlag() {
84+
assertEquals(LATIN, tagString(frame(UTF_16BE_FLAG, bytes(LATIN, UTF_16BE))));
85+
assertEquals(CJK, tagString(frame(UTF_16BE_FLAG, bytes(CJK, UTF_16BE))));
86+
}
87+
88+
// $01 promises a BOM; omitting it used to decode LE as BE mojibake ('T' 0x54 0x00 -> U+5400)
89+
@Test
90+
public void testUTF16WithoutBOMRecoversByteOrder() {
91+
assertEquals(LATIN, tagString(frame(UTF_16_BOM_FLAG, bytes(LATIN, UTF_16LE))));
92+
assertEquals(LATIN, tagString(frame(UTF_16_BOM_FLAG, bytes(LATIN, UTF_16BE))));
93+
}
94+
95+
// no NUL bytes (all chars above U+00FF) means no signal, so keep the big-endian default
96+
@Test
97+
public void testUTF16WithoutBOMKeepsBigEndianDefaultWhenNoSignal() {
98+
assertEquals(CJK, tagString(frame(UTF_16_BOM_FLAG, bytes(CJK, UTF_16BE))));
99+
}
100+
101+
@Test
102+
public void testNullTerminationIsTrimmed() {
103+
byte[] doubleNul = {0, 0};
104+
byte[] singleNul = {0};
105+
assertEquals(LATIN, tagString(frame(UTF_16_BOM_FLAG, bytes(LATIN, UTF_16LE), doubleNul)));
106+
assertEquals(LATIN,
107+
tagString(frame(UTF_16_BOM_FLAG, BOM_LE, bytes(LATIN, UTF_16LE), doubleNul)));
108+
assertEquals(LATIN, tagString(frame(ISO_8859_1_FLAG, bytes(LATIN, ISO_8859_1), singleNul)));
109+
}
110+
111+
// TIKA-1024: a frame holding nothing but a BOM decodes to the empty string
112+
@Test
113+
public void testNakedBOM() {
114+
assertEquals("", tagString(frame(UTF_16_BOM_FLAG, BOM_LE)));
115+
assertEquals("", tagString(frame(UTF_16_BOM_FLAG, BOM_BE)));
116+
}
117+
118+
// COMM decodes description and text separately, so each recovers byte order on its own
119+
@Test
120+
public void testCommentWithoutBOMRecoversByteOrder() throws IOException {
121+
ByteArrayOutputStream out = new ByteArrayOutputStream();
122+
out.write(UTF_16_BOM_FLAG);
123+
out.write(bytes("eng", ISO_8859_1));
124+
out.write(bytes("Desc", UTF_16LE));
125+
out.write(new byte[]{0, 0});
126+
out.write(bytes(LATIN, UTF_16LE));
127+
byte[] data = out.toByteArray();
128+
129+
ID3Comment comment = ID3v2Frame.getComment(data, 0, data.length);
130+
assertEquals("eng", comment.getLanguage());
131+
assertEquals("Desc", comment.getDescription());
132+
assertEquals(LATIN, comment.getText());
133+
}
134+
135+
@Test
136+
public void testCommentWithBOM() throws IOException {
137+
ByteArrayOutputStream out = new ByteArrayOutputStream();
138+
out.write(UTF_16_BOM_FLAG);
139+
out.write(bytes("eng", ISO_8859_1));
140+
out.write(BOM_LE);
141+
out.write(bytes("Desc", UTF_16LE));
142+
out.write(new byte[]{0, 0});
143+
out.write(BOM_LE);
144+
out.write(bytes(LATIN, UTF_16LE));
145+
byte[] data = out.toByteArray();
146+
147+
ID3Comment comment = ID3v2Frame.getComment(data, 0, data.length);
148+
assertEquals("eng", comment.getLanguage());
149+
assertEquals("Desc", comment.getDescription());
150+
assertEquals(LATIN, comment.getText());
151+
}
152+
153+
// too short for flag + 3 byte language, or an unknown flag, decodes to null (no overrun)
154+
@Test
155+
public void testMalformedCommentsReturnNull() {
156+
assertNull(ID3v2Frame.getComment(new byte[0], 0, 0));
157+
assertNull(ID3v2Frame.getComment(new byte[]{UTF_16_BOM_FLAG}, 0, 1));
158+
assertNull(ID3v2Frame.getComment(new byte[]{ISO_8859_1_FLAG, 'e', 'n'}, 0, 3));
159+
// 0x05 is not a defined ID3v2 text encoding
160+
byte[] badFlag = {5, 'e', 'n', 'g', 'D', 'e', 's', 'c', 0, 'T'};
161+
assertNull(ID3v2Frame.getComment(badFlag, 0, badFlag.length));
162+
}
163+
164+
// a double byte comment ending on a lone NUL must not overrun looking for a terminator
165+
@Test
166+
public void testCommentTruncatedOnTerminatorDoesNotOverrun() {
167+
byte[] data = {UTF_16_BOM_FLAG, 'e', 'n', 'g', 0};
168+
ID3Comment comment = ID3v2Frame.getComment(data, 0, data.length);
169+
assertEquals("eng", comment.getLanguage());
170+
}
171+
}

0 commit comments

Comments
 (0)