This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new 5e655ab108 TIKA-4890: improve robustness on length check (#3210)
5e655ab108 is described below
commit 5e655ab1082953eeab6755dc4c088e69dcf41213
Author: Tim Allison <[email protected]>
AuthorDate: Sat Sep 19 08:43:08 2026 -0400
TIKA-4890: improve robustness on length check (#3210)
---
CHANGES.txt | 2 +-
.../org/apache/tika/parser/mail/RFC822Parser.java | 63 ++++++---
.../java/org/apache/tika/parser/mail/EMLXTest.java | 147 +++++++++++++++++----
3 files changed, 171 insertions(+), 41 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 077021aee5..38f867a215 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -3,7 +3,7 @@ Release 4.1.0 - unreleased
* Apple Mail emlx files are detected as message/x-emlx (real files pad the
byte-count line to 10 columns and were falling through to text/html or
message/rfc822), and RFC822Parser now parses them, dropping the byte-count
- line and the trailing plist (TIKA-4890).
+ line and, when the count is intact, the trailing plist (TIKA-4890).
* The tika-server UAT gains an inference pass (TIKA-4913).
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/main/java/org/apache/tika/parser/mail/RFC822Parser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/main/java/org/apache/tika/parser/mail/RFC822Parser.java
index cee6a203b1..391d8a0294 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/main/java/org/apache/tika/parser/mail/RFC822Parser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/main/java/org/apache/tika/parser/mail/RFC822Parser.java
@@ -16,13 +16,18 @@
*/
package org.apache.tika.parser.mail;
+import static java.nio.charset.StandardCharsets.US_ASCII;
+
import java.io.IOException;
import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.SeekableByteChannel;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
+import org.apache.commons.io.IOUtils;
import org.apache.james.mime4j.MimeException;
import org.apache.james.mime4j.message.DefaultBodyDescriptorBuilder;
import org.apache.james.mime4j.parser.MimeStreamParser;
@@ -50,7 +55,9 @@ import org.apache.tika.sax.XHTMLContentHandler;
* corresponding parser and displayed within elements.
* <p/>
* Also handles Apple Mail's emlx framing: a first line carrying the message's
- * byte count, and an XML plist after the message. Both are dropped.
+ * byte count, and an XML plist after the message. Both are dropped when the
+ * count checks out; an edited file whose count no longer lands on the plist
+ * is parsed whole rather than cut.
* <p/>
* A {@link MimeConfig} object can be passed in the parsing context
* to better control the parsing process.
@@ -86,6 +93,8 @@ public class RFC822Parser implements Parser {
// digits, then Apple Mail's space padding to 10 columns, then the newline
private static final int EMLX_COUNT_LINE_MAX = 11;
+ private static final byte[] PLIST_START = "<?xml".getBytes(US_ASCII);
+
//rely on the detector to be thread-safe
//built lazily and then reused
private Detector detector;
@@ -145,12 +154,7 @@ public class RFC822Parser implements Parser {
xhtml.startDocument();
checkForZeroByte(tis);//avoid stackoverflow
try {
- InputStream message = tis;
- long emlxLength = readEmlxByteCount(tis);
- if (emlxLength >= 0) {
- message = new BoundedInputStream(emlxLength, tis);
- }
- parser.parse(message);
+ parser.parse(stripEmlxFraming(tis));
} catch (IOException e) {
tis.throwIfCauseOf(e);
throw new TikaException("Failed to parse an email message", e);
@@ -169,30 +173,57 @@ public class RFC822Parser implements Parser {
}
/**
- * Consumes the emlx byte-count line if the stream starts with one.
+ * Consumes the emlx byte-count line if the stream starts with one, and
bounds the
+ * message to that count when the plist really starts there.
*
- * @return the message's length in bytes, or -1 if the stream is not emlx
framed
+ * @return the stream to hand to mime4j
*/
- private static long readEmlxByteCount(TikaInputStream tis) throws
IOException {
- tis.mark(EMLX_COUNT_LINE_MAX);
+ private static InputStream stripEmlxFraming(TikaInputStream tis) throws
IOException {
+ byte[] head = new byte[EMLX_COUNT_LINE_MAX];
+ int n = tis.peek(head);
long count = 0;
int digits = 0;
boolean padding = false;
- for (int i = 0; i < EMLX_COUNT_LINE_MAX; i++) {
- int c = tis.read();
+ int lineLength = -1;
+ for (int i = 0; i < n; i++) {
+ int c = head[i];
if (c >= '0' && c <= '9' && !padding) {
count = count * 10 + (c - '0');
digits++;
} else if (c == ' ' && digits > 0) {
padding = true;
} else if (c == '\n' && digits > 0) {
- return count;
+ lineLength = i + 1;
+ break;
} else {
break;
}
}
- tis.reset();
- return -1;
+ if (lineLength < 0) {
+ return tis;
+ }
+ boolean verified = plistStartsAt(tis, lineLength + count);
+ IOUtils.skipFully(tis, lineLength);
+ return verified ? new BoundedInputStream(count, tis) : tis;
+ }
+
+ private static boolean plistStartsAt(TikaInputStream tis, long offset) {
+ // must run before any byte is consumed: a passthrough source refuses
a seekable
+ // view once its position has moved
+ try (SeekableByteChannel channel = tis.getSeekableByteChannel()) {
+ if (offset + PLIST_START.length > channel.size()) {
+ return false;
+ }
+ ByteBuffer buffer = ByteBuffer.allocate(PLIST_START.length);
+ channel.position(offset);
+ while (buffer.hasRemaining() && channel.read(buffer) != -1) {
+ // fill
+ }
+ return !buffer.hasRemaining() && Arrays.equals(buffer.array(),
PLIST_START);
+ } catch (IOException e) {
+ // unverifiable is not a reason to cut; a real read failure recurs
in mime4j
+ return false;
+ }
}
private void checkForZeroByte(TikaInputStream tstream) throws IOException,
ZeroByteFileException {
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/test/java/org/apache/tika/parser/mail/EMLXTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/test/java/org/apache/tika/parser/mail/EMLXTest.java
index 33a8fd4a25..c4ce2c37cd 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/test/java/org/apache/tika/parser/mail/EMLXTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-mail-module/src/test/java/org/apache/tika/parser/mail/EMLXTest.java
@@ -16,11 +16,20 @@
*/
package org.apache.tika.parser.mail;
+import static java.nio.charset.StandardCharsets.US_ASCII;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
-import java.nio.charset.StandardCharsets;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
import org.apache.tika.TikaTest;
import org.apache.tika.io.TikaInputStream;
@@ -37,65 +46,155 @@ import org.apache.tika.parser.Parser;
*/
public class EMLXTest extends TikaTest {
+ private static final String MULTIPART = "testEMLX_multipart_html.emlx";
+ private static final String MULTIPART_TEXT =
+ "The plan for the Foundation is in this message’s HTML part.";
+ private static final String SINGLE_PART = "testEMLX_single_part_html.emlx";
+ private static final String SINGLE_PART_TEXT = "The trial balance is ready
for review.";
+
private static final String PLIST = "<?xml version=\"1.0\"
encoding=\"UTF-8\"?>\n" +
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" " +
"\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n" +
"<plist version=\"1.0\">\n<dict>\n\t<key>date-received</key>\n" +
"\t<integer>1698393810</integer>\n</dict>\n</plist>\n";
- @Test
- public void testLongHeaderBlockWithHtmlAlternative() throws Exception {
- XMLResult r = getXML("testEMLX_multipart_html.emlx");
+ /** The three TikaInputStream backings; the count check seeks differently
in each. */
+ enum Source { BYTES, STREAM, FILE }
+
+ @TempDir
+ Path tmp;
+
+ @ParameterizedTest
+ @EnumSource(Source.class)
+ public void testLongHeaderBlockWithHtmlAlternative(Source source) throws
Exception {
+ XMLResult r = parse(fixture(MULTIPART), source);
assertEquals("message/x-emlx",
r.metadata.get(HttpHeaders.CONTENT_TYPE));
assertEquals("Set the Foundation Straight",
r.metadata.get(TikaCoreProperties.TITLE));
assertEquals("[email protected]",
r.metadata.get(Message.MESSAGE_FROM_EMAIL));
assertEquals("2015-09-26T16:30:20Z",
r.metadata.get(TikaCoreProperties.CREATED));
- assertContains("The plan for the Foundation is in this message’s HTML
part.", r.xml);
+ assertContains(MULTIPART_TEXT, r.xml);
assertPlistAndCountDropped(r.xml, "2803");
}
- @Test
- public void testSinglePartHtml() throws Exception {
- XMLResult r = getXML("testEMLX_single_part_html.emlx");
+ @ParameterizedTest
+ @EnumSource(Source.class)
+ public void testSinglePartHtml(Source source) throws Exception {
+ XMLResult r = parse(fixture(SINGLE_PART), source);
assertEquals("message/x-emlx",
r.metadata.get(HttpHeaders.CONTENT_TYPE));
assertEquals("Trial balance",
r.metadata.get(TikaCoreProperties.TITLE));
- assertContains("The trial balance is ready for review.", r.xml);
+ assertContains(SINGLE_PART_TEXT, r.xml);
assertPlistAndCountDropped(r.xml, "446");
}
+ @ParameterizedTest
+ @EnumSource(Source.class)
+ public void testStaleCountIsNotTrusted(Source source) throws Exception {
+ // an edited file whose count no longer lands on the plist must not be
cut
+ for (String fixture : new String[]{MULTIPART, SINGLE_PART}) {
+ byte[] good = fixture(fixture);
+ String text = fixture.equals(MULTIPART) ? MULTIPART_TEXT :
SINGLE_PART_TEXT;
+ for (long bad : new long[]{0, 100, 1500, 99999}) {
+ XMLResult r = parse(withCount(good, bad), source);
+ assertEquals("message/x-emlx",
r.metadata.get(HttpHeaders.CONTENT_TYPE),
+ fixture + " count=" + bad);
+ assertContains(text, r.xml);
+ assertNotContained(paddedCount(bad), body(r.xml));
+ }
+ }
+ }
+
@Test
public void testUnpaddedByteCount() throws Exception {
String message = "Subject: Unpadded\nFrom: [email protected]\nTo:
[email protected]\n\n" +
"the body\n";
- String emlx = message.length() + "\n" + message + PLIST;
- try (TikaInputStream tis =
TikaInputStream.get(emlx.getBytes(StandardCharsets.US_ASCII))) {
- XMLResult r = getXML(tis, new AutoDetectParser(), new Metadata());
- assertEquals("message/x-emlx",
r.metadata.get(HttpHeaders.CONTENT_TYPE));
- assertEquals("Unpadded", r.metadata.get(TikaCoreProperties.TITLE));
- assertContains("the body", r.xml);
- assertPlistAndCountDropped(r.xml,
Integer.toString(message.length()));
- }
+ byte[] emlx = (message.length() + "\n" + message +
PLIST).getBytes(US_ASCII);
+ XMLResult r = parse(emlx, Source.BYTES);
+ assertEquals("message/x-emlx",
r.metadata.get(HttpHeaders.CONTENT_TYPE));
+ assertEquals("Unpadded", r.metadata.get(TikaCoreProperties.TITLE));
+ assertContains("the body", r.xml);
+ assertPlistAndCountDropped(r.xml, Integer.toString(message.length()));
}
@Test
public void testForcedTypeStillStripsFraming() throws Exception {
// an emlx handed to the parser as message/rfc822 must not leak the
plist either
String message = "Subject: Forced\nFrom: [email protected]\n\nthe
body\n";
- String emlx = (message.length() + " \n") + message + PLIST;
+ byte[] emlx = ((message.length() + " \n") + message +
PLIST).getBytes(US_ASCII);
+ XMLResult r = parseAsRfc822(emlx);
+ assertEquals("Forced", r.metadata.get(TikaCoreProperties.TITLE));
+ assertContains("the body", r.xml);
+ assertPlistAndCountDropped(r.xml, Integer.toString(message.length()));
+ }
+
+ @Test
+ public void testCrlfIsNotFraming() throws Exception {
+ // a CRLF-converted file has a stale count and a "\r\n" count line:
left alone
+ byte[] crlf = new String(fixture(SINGLE_PART), US_ASCII).replace("\n",
"\r\n")
+ .getBytes(US_ASCII);
+ XMLResult r = parseAsRfc822(crlf);
+ assertEquals("Trial balance",
r.metadata.get(TikaCoreProperties.TITLE));
+ assertContains(SINGLE_PART_TEXT, r.xml);
+ }
+
+ private XMLResult parse(byte[] emlx, Source source) throws Exception {
+ try (TikaInputStream tis = open(emlx, source)) {
+ return getXML(tis, new AutoDetectParser(), new Metadata());
+ }
+ }
+
+ private XMLResult parseAsRfc822(byte[] bytes) throws Exception {
Metadata metadata = new Metadata();
metadata.set(HttpHeaders.CONTENT_TYPE, "message/rfc822");
ParseContext context = new ParseContext();
context.set(Parser.class, new AutoDetectParser());
- try (TikaInputStream tis =
TikaInputStream.get(emlx.getBytes(StandardCharsets.US_ASCII))) {
- XMLResult r = getXML(tis, new RFC822Parser(), metadata, context);
- assertEquals("Forced", r.metadata.get(TikaCoreProperties.TITLE));
- assertContains("the body", r.xml);
- assertPlistAndCountDropped(r.xml,
Integer.toString(message.length()));
+ try (TikaInputStream tis = TikaInputStream.get(bytes)) {
+ return getXML(tis, new RFC822Parser(), metadata, context);
+ }
+ }
+
+ private TikaInputStream open(byte[] bytes, Source source) throws
IOException {
+ switch (source) {
+ case BYTES:
+ return TikaInputStream.get(bytes);
+ case STREAM:
+ return TikaInputStream.get(new ByteArrayInputStream(bytes));
+ default:
+ Path p = Files.createTempFile(tmp, "emlx", ".emlx");
+ Files.write(p, bytes);
+ return TikaInputStream.get(p);
+ }
+ }
+
+ private static byte[] fixture(String name) throws IOException {
+ try (InputStream in =
EMLXTest.class.getResourceAsStream("/test-documents/" + name)) {
+ assertNotNull(in, name);
+ return in.readAllBytes();
+ }
+ }
+
+ /** Replaces the fixture's 10-column count line. */
+ private static byte[] withCount(byte[] emlx, long count) {
+ byte[] line = (paddedCount(count) + "\n").getBytes(US_ASCII);
+ byte[] out = new byte[emlx.length];
+ System.arraycopy(line, 0, out, 0, 11);
+ System.arraycopy(emlx, 11, out, 11, emlx.length - 11);
+ return out;
+ }
+
+ private static String paddedCount(long count) {
+ StringBuilder sb = new StringBuilder(Long.toString(count));
+ while (sb.length() < 10) {
+ sb.append(' ');
}
+ return sb.toString();
+ }
+
+ private static String body(String xml) {
+ return xml.substring(xml.indexOf("<body"));
}
private static void assertPlistAndCountDropped(String xml, String
byteCount) {
- String body = xml.substring(xml.indexOf("<body"));
+ String body = body(xml);
assertNotContained(byteCount, body);
assertNotContained("date-received", body);
assertNotContained("plist", body);