From 40f320bcf9029344a0361ac9d22a20f661f653e1 Mon Sep 17 00:00:00 2001 From: Andreas Beeker Date: Sun, 8 Mar 2020 23:26:53 +0000 Subject: [PATCH] github-167 - HSMF enhancements introduce NameIdChunks.GetPropertyTag: which enables evaluating property ids from properties identified by name/id in property sets (simple version of IMAPIProp::GetIDsFromNames) AttachmentChunks.getAttachData: use new ByteChunkDeferred instead of ByteChunk which enables delayed reading of attachments to avoid all attachments are completely read into memory when parsing which may cause OutOfMemoryErrors on e-mails with big attachments. POIFSChunkParser: support reading multi valued chunks (e.g. required when reading the Keywords ("categories") property) add MAPIProperty.RECEIVED_BY_SMTP_ADDRESS add unit tests git-svn-id: https://svn.apache.org/repos/asf/poi/trunk@1874990 13f79535-47bb-0310-9956-ffa450edef68 --- .../poi/hsmf/datatypes/ByteChunkDeferred.java | 100 +++++ .../org/apache/poi/hsmf/datatypes/Chunks.java | 22 +- .../poi/hsmf/datatypes/MAPIProperty.java | 7 +- .../poi/hsmf/datatypes/NameIdChunks.java | 219 ++++++++++ .../poi/hsmf/parsers/POIFSChunkParser.java | 392 +++++++++++------- .../poi/hsmf/TestFileWithAttachmentsRead.java | 41 +- .../org/apache/poi/hsmf/TestNameIdChunks.java | 89 ++++ test-data/hsmf/keywords.msg | Bin 0 -> 21504 bytes 8 files changed, 690 insertions(+), 180 deletions(-) create mode 100644 src/scratchpad/src/org/apache/poi/hsmf/datatypes/ByteChunkDeferred.java create mode 100644 src/scratchpad/testcases/org/apache/poi/hsmf/TestNameIdChunks.java create mode 100644 test-data/hsmf/keywords.msg diff --git a/src/scratchpad/src/org/apache/poi/hsmf/datatypes/ByteChunkDeferred.java b/src/scratchpad/src/org/apache/poi/hsmf/datatypes/ByteChunkDeferred.java new file mode 100644 index 0000000000..ce977b83b1 --- /dev/null +++ b/src/scratchpad/src/org/apache/poi/hsmf/datatypes/ByteChunkDeferred.java @@ -0,0 +1,100 @@ +/* ==================================================================== + 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.poi.hsmf.datatypes; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.apache.poi.hsmf.datatypes.Types.MAPIType; +import org.apache.poi.poifs.filesystem.DirectoryNode; +import org.apache.poi.poifs.filesystem.DocumentInputStream; +import org.apache.poi.poifs.filesystem.DocumentNode; +import org.apache.poi.util.IOUtils; + +/** + * A Chunk that either acts as {@link ByteChunk} (if not initialized with a node) or + * lazy loads its binary data from the document (if linked with a node via {@link #readValue(DocumentNode)}). + */ +public class ByteChunkDeferred extends ByteChunk { + + private DocumentNode node; + + /** + * Creates a Byte Stream Chunk, with the specified type. + */ + public ByteChunkDeferred(String namePrefix, int chunkId, MAPIType type) { + super(namePrefix, chunkId, type); + } + + /** + * Links the chunk to a document + * @param node the document node + */ + public void readValue(DocumentNode node) { + this.node = node; + } + + public void readValue(InputStream value) throws IOException { + if (node == null) { + super.readValue(value); + } + } + + @Override + public void writeValue(OutputStream out) throws IOException { + if (node == null) { + super.writeValue(out); + return; + } + + try (DocumentInputStream dis = createDocumentInputStream()) { + IOUtils.copy(dis, out); + } + } + + /** + * Get bytes directly. + */ + public byte[] getValue() { + if (node == null) { + return super.getValue(); + } + + try (DocumentInputStream dis = createDocumentInputStream()) { + return IOUtils.toByteArray(dis, node.getSize()); + } catch (IOException e) { + return null; + } + } + + /** + * Set bytes directly. + *

+ * updating the linked document node/msg file directly would be unexpected, + * so we remove the link and act as a ByteChunk from then + */ + public void setValue(byte[] value) { + node = null; + super.setValue(value); + } + + private DocumentInputStream createDocumentInputStream() throws IOException { + return ((DirectoryNode) node.getParent()).createDocumentInputStream(node); + } +} diff --git a/src/scratchpad/src/org/apache/poi/hsmf/datatypes/Chunks.java b/src/scratchpad/src/org/apache/poi/hsmf/datatypes/Chunks.java index c2014a5032..5da2f5bd43 100644 --- a/src/scratchpad/src/org/apache/poi/hsmf/datatypes/Chunks.java +++ b/src/scratchpad/src/org/apache/poi/hsmf/datatypes/Chunks.java @@ -28,12 +28,12 @@ import org.apache.poi.util.POILogger; /** * Collection of convenience chunks for standard parts of the MSG file. - * + * * Not all of these will be present in any given file. - * + * * A partial list is available at: * http://msdn.microsoft.com/en-us/library/ms526356%28v=exchg.10%29.aspx - * + * * TODO Deprecate the public Chunks in favour of Property Lookups */ public final class Chunks implements ChunkGroupWithProperties { @@ -44,7 +44,13 @@ public final class Chunks implements ChunkGroupWithProperties { * Normally a property will have zero chunks (fixed sized) or one chunk * (variable size), but in some cases (eg Unknown) you may get more. */ - private Map> allChunks = new HashMap<>(); + private final Map> allChunks = new HashMap<>(); + + /** + * Holds all the unknown properties that were found, indexed by their property id and property type. + * All unknown properties have a custom properties instance. + */ + private final Map unknownProperties = new HashMap<>(); /** Type of message that the MSG represents (ie. IPM.Note) */ private StringChunk messageClass; @@ -188,6 +194,14 @@ public final class Chunks implements ChunkGroupWithProperties { public void record(Chunk chunk) { // Work out what MAPIProperty this corresponds to MAPIProperty prop = MAPIProperty.get(chunk.getChunkId()); + if (prop == MAPIProperty.UNKNOWN) { + long id = (chunk.getChunkId() << 16) + chunk.getType().getId(); + prop = unknownProperties.get(id); + if (prop == null) { + prop = MAPIProperty.createCustom(chunk.getChunkId(), chunk.getType(), chunk.getEntryName()); + unknownProperties.put(id, prop); + } + } // Assign it for easy lookup, as best we can if (prop == MAPIProperty.MESSAGE_CLASS) { diff --git a/src/scratchpad/src/org/apache/poi/hsmf/datatypes/MAPIProperty.java b/src/scratchpad/src/org/apache/poi/hsmf/datatypes/MAPIProperty.java index 28c3b8c00c..81c431b2b7 100644 --- a/src/scratchpad/src/org/apache/poi/hsmf/datatypes/MAPIProperty.java +++ b/src/scratchpad/src/org/apache/poi/hsmf/datatypes/MAPIProperty.java @@ -43,6 +43,7 @@ import org.apache.poi.hsmf.datatypes.Types.MAPIType; * https://msdn.microsoft.com/en-us/library/microsoft.exchange.data.contenttypes.tnef.tnefpropertyid(v=exchg.150).aspx * http://msdn.microsoft.com/en-us/library/ms526356%28v=exchg.10%29.aspx */ +@SuppressWarnings("unused") public class MAPIProperty { private static Map attributes = new HashMap<>(); @@ -790,6 +791,8 @@ public class MAPIProperty { new MAPIProperty(0x3f, BINARY, "ReceivedByEntryId", "PR_RECEIVED_BY_ENTRYID"); public static final MAPIProperty RECEIVED_BY_NAME = new MAPIProperty(0x40, ASCII_STRING, "ReceivedByName", "PR_RECEIVED_BY_NAME"); + public static final MAPIProperty RECEIVED_BY_SMTP_ADDRESS = + new MAPIProperty(0x5D07, ASCII_STRING, "ReceivedBySmtpAddress", "PR_RECEIVED_BY_SMTP_ADDRESS"); public static final MAPIProperty RECIPIENT_DISPLAY_NAME = new MAPIProperty(0x5ff6, Types.UNICODE_STRING, "RecipientDisplayName", null); public static final MAPIProperty RECIPIENT_ENTRY_ID = @@ -1050,7 +1053,7 @@ public class MAPIProperty { this.mapiProperty = mapiProperty; // If it isn't unknown or custom, store it for lookup - if (id == -1 + if (id == -1 || (id >= ID_FIRST_CUSTOM && id <= ID_LAST_CUSTOM) || (this instanceof CustomMAPIProperty)) { // Custom/Unknown, skip @@ -1095,7 +1098,7 @@ public class MAPIProperty { return new CustomMAPIProperty(id, type, name, null); } - private static class CustomMAPIProperty extends MAPIProperty { + private static final class CustomMAPIProperty extends MAPIProperty { private CustomMAPIProperty(int id, MAPIType usualType, String name, String mapiProperty) { super(id, usualType, name, mapiProperty); } diff --git a/src/scratchpad/src/org/apache/poi/hsmf/datatypes/NameIdChunks.java b/src/scratchpad/src/org/apache/poi/hsmf/datatypes/NameIdChunks.java index 71fb2ef7df..4c352413ba 100644 --- a/src/scratchpad/src/org/apache/poi/hsmf/datatypes/NameIdChunks.java +++ b/src/scratchpad/src/org/apache/poi/hsmf/datatypes/NameIdChunks.java @@ -19,6 +19,14 @@ package org.apache.poi.hsmf.datatypes; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; + +import org.apache.commons.codec.digest.PureJavaCrc32; +import org.apache.poi.hpsf.ClassID; +import org.apache.poi.util.LittleEndian; +import org.apache.poi.util.LittleEndianByteArrayInputStream; +import org.apache.poi.util.StringUtil; /** * Collection of convenience chunks for the NameID part of an outlook file @@ -26,6 +34,43 @@ import java.util.List; public final class NameIdChunks implements ChunkGroup { public static final String NAME = "__nameid_version1.0"; + public enum PropertySetType { + PS_MAPI("00020328-0000-0000-C000-000000000046"), + PS_PUBLIC_STRINGS("00020329-0000-0000-C000-000000000046"), + PS_INTERNET_HEADERS("00020386-0000-0000-C000-000000000046"); + + public ClassID classID; + PropertySetType(String uuid) { + classID = new ClassID(uuid); + } + } + + public enum PredefinedPropertySet { + PSETID_COMMON("00062008-0000-0000-C000-000000000046"), + PSETID_ADDRESS("00062004-0000-0000-C000-000000000046"), + PSETID_APPOINTMENT("00062002-0000-0000-C000-000000000046"), + PSETID_MEETING("6ED8DA90-450B-101B-98DA-00AA003F1305"), + PSETID_LOG("0006200A-0000-0000-C000-000000000046"), + PSETID_MESSAGING("41F28F13-83F4-4114-A584-EEDB5A6B0BFF"), + PSETID_NOTE("0006200E-0000-0000-C000-000000000046"), + PSETID_POST_RSS("00062041-0000-0000-C000-000000000046"), + PSETID_TASK("00062003-0000-0000-C000-000000000046"), + PSETID_UNIFIED_MESSAGING("4442858E-A9E3-4E80-B900-317A210CC15B"), + PSETID_AIR_SYNC("71035549-0739-4DCB-9163-00F0580DBBDF"), + PSETID_SHARING("00062040-0000-0000-C000-000000000046"), + PSETID_XML_EXTRACTED_ENTITIES("23239608-685D-4732-9C55-4C95CB4E8E33"), + PSETID_ATTACHMENT("96357F7F-59E1-47D0-99A7-46515C183B54"); + + public ClassID classID; + PredefinedPropertySet(String uuid) { + classID = new ClassID(uuid); + } + } + + private ByteChunk guidStream; + private ByteChunk entryStream; + private ByteChunk stringStream; + /** Holds all the chunks that were found. */ private List allChunks = new ArrayList<>(); @@ -43,6 +88,19 @@ public final class NameIdChunks implements ChunkGroup { */ @Override public void record(Chunk chunk) { + if (chunk.getType() == Types.BINARY) { + switch (chunk.getChunkId()) { + case 2: + guidStream = (ByteChunk)chunk; + break; + case 3: + entryStream = (ByteChunk)chunk; + break; + case 4: + stringStream = (ByteChunk)chunk; + break; + } + } allChunks.add(chunk); } @@ -54,4 +112,165 @@ public final class NameIdChunks implements ChunkGroup { // Currently, we don't need to do anything special once // all the chunks have been located } + + /** + * Get property tag id by property set GUID and string name or numerical name from named properties mapping + * @param guid Property set GUID in registry format without brackets. + * May be one of the PS_* or PSETID_* constants + * @param name Property name in case of string named property + * @param id Property id in case of numerical named property + * @return Property tag which can be matched with {@link org.apache.poi.hsmf.datatypes.MAPIProperty#id} + * or 0 if the property could not be found. + * + */ + public long getPropertyTag(ClassID guid, String name, long id) { + final byte[] entryStreamBytes = (entryStream == null) ? null : entryStream.getValue(); + if (guidStream == null || entryStream == null || stringStream == null || guid == null || + entryStreamBytes == null) { + return 0; + } + + LittleEndianByteArrayInputStream leis = new LittleEndianByteArrayInputStream(entryStreamBytes); + for (int i = 0; i < entryStreamBytes.length / 8; i++) { + final long nameOffset = leis.readUInt(); + int guidIndex = leis.readUShort(); + final int propertyKind = guidIndex & 0x01; + guidIndex = guidIndex >>> 1; + final int propertyIndex = leis.readUShort(); + + // fetch and match property GUID + if (!guid.equals(getPropertyGUID(guidIndex))) { + continue; + } + + // fetch property name / stream ID + final String[] propertyName = { null }; + final long[] propertyNameCRC32 = { -1L }; + long streamID = getStreamID(propertyKind, (int)nameOffset, guid, guidIndex, + n -> propertyName[0] = n, c -> propertyNameCRC32[0] = c); + + if (!matchesProperty(propertyKind, nameOffset, name, propertyName[0], id)) { + continue; + } + + // find property index in matching stream entry + if (propertyKind == 1 && propertyNameCRC32[0] < 0) { + // skip stream entry matching and return tag from property index from entry stream + // this code should not be reached + return 0x8000 + propertyIndex; + } + + return getPropertyTag(streamID, nameOffset, propertyNameCRC32[0]); + } + return 0; + } + + private long getPropertyTag(long streamID, long nameOffset, long propertyNameCRC32) { + for (Chunk chunk : allChunks) { + if (chunk.getType() != Types.BINARY || chunk.getChunkId() != streamID) { + continue; + } + byte[] matchChunkBytes = ((ByteChunk) chunk).getValue(); + if (matchChunkBytes == null) { + continue; + } + LittleEndianByteArrayInputStream leis = new LittleEndianByteArrayInputStream(matchChunkBytes); + for (int m = 0; m < matchChunkBytes.length / 8; m++) { + long nameCRC = leis.readUInt(); + int matchGuidIndex = leis.readUShort(); + int matchPropertyIndex = leis.readUShort(); + int matchPropertyKind = matchGuidIndex & 0x01; + + if (nameCRC == (matchPropertyKind == 0 ? nameOffset : propertyNameCRC32)) { + return 0x8000 + matchPropertyIndex; + } + } + } + return 0; + } + + private ClassID getPropertyGUID(int guidIndex) { + if (guidIndex == 1) { + // predefined GUID + return PropertySetType.PS_MAPI.classID; + } else if (guidIndex == 2) { + // predefined GUID + return PropertySetType.PS_PUBLIC_STRINGS.classID; + } else if (guidIndex >= 3) { + // GUID from guid stream + byte[] guidStreamBytes = guidStream.getValue(); + int guidIndexOffset = (guidIndex - 3) * 0x10; + if (guidStreamBytes.length >= guidIndexOffset + 0x10) { + return new ClassID(guidStreamBytes, guidIndexOffset); + } + } + return null; + } + + // property set GUID matches + private static boolean matchesProperty(int propertyKind, long nameOffset, String name, String propertyName, long id) { + return + // match property by id + (propertyKind == 0 && id >= 0 && id == nameOffset) || + // match property by name + (propertyKind == 1 && name != null && name.equals(propertyName)); + } + + + private long getStreamID(int propertyKind, int nameOffset, ClassID guid, int guidIndex, + Consumer propertyNameSetter, Consumer propertyNameCRC32Setter) { + if (propertyKind == 0) { + // numerical named property + return 0x1000 + (nameOffset ^ (guidIndex << 1)) % 0x1F; + } + + // string named property + byte[] stringBytes = stringStream.getValue(); + long propertyNameCRC32 = -1; + if (stringBytes.length > nameOffset) { + long nameLength = LittleEndian.getUInt(stringBytes, nameOffset); + if (stringBytes.length >= nameOffset + 4 + nameLength) { + int nameStart = nameOffset + 4; + String propertyName = new String(stringBytes, nameStart, (int) nameLength, StringUtil.UTF16LE); + if (PropertySetType.PS_INTERNET_HEADERS.classID.equals(guid)) { + byte[] n = propertyName.toLowerCase(Locale.ROOT).getBytes(StringUtil.UTF16LE); + propertyNameCRC32 = calculateCRC32(n, 0, n.length); + } else { + propertyNameCRC32 = calculateCRC32(stringBytes, nameStart, (int)nameLength); + } + propertyNameSetter.accept(propertyName); + propertyNameCRC32Setter.accept(propertyNameCRC32); + } + } + return 0x1000 + (propertyNameCRC32 ^ ((guidIndex << 1) | 1)) % 0x1F; + } + + /** + * Calculates the CRC32 of the given bytes (conforms to RFC 1510, SSH-1). + * The CRC32 calculation is similar to the standard one as demonstrated in RFC 1952, + * but with the inversion (before and after the calculation) omitted. + *

+ * + * @param buf the byte array to calculate CRC32 on + * @param off the offset within buf at which the CRC32 calculation will start + * @param len the number of bytes on which to calculate the CRC32 + * @return the CRC32 value (unsigned 32-bit integer stored in a long). + * + * @see CRC parameter check + */ + private static long calculateCRC32(byte[] buf, int off, int len) { + PureJavaCrc32 crc = new PureJavaCrc32(); + // set initial crc value to 0 + crc.update( new byte[] {-1,-1,-1,-1}, 0, 4); + crc.update(buf, off, len); + return ~crc.getValue() & 0xFFFFFFFFL; + } + } diff --git a/src/scratchpad/src/org/apache/poi/hsmf/parsers/POIFSChunkParser.java b/src/scratchpad/src/org/apache/poi/hsmf/parsers/POIFSChunkParser.java index 980cf0a24b..d0e8caf66c 100644 --- a/src/scratchpad/src/org/apache/poi/hsmf/parsers/POIFSChunkParser.java +++ b/src/scratchpad/src/org/apache/poi/hsmf/parsers/POIFSChunkParser.java @@ -18,10 +18,15 @@ package org.apache.poi.hsmf.parsers; import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; import org.apache.poi.hsmf.datatypes.AttachmentChunks; import org.apache.poi.hsmf.datatypes.ByteChunk; +import org.apache.poi.hsmf.datatypes.ByteChunkDeferred; import org.apache.poi.hsmf.datatypes.Chunk; import org.apache.poi.hsmf.datatypes.ChunkGroup; import org.apache.poi.hsmf.datatypes.Chunks; @@ -50,171 +55,248 @@ import org.apache.poi.util.POILogger; * data and so on. */ public final class POIFSChunkParser { - private final static POILogger logger = POILogFactory.getLogger(POIFSChunkParser.class); + private static final POILogger LOG = POILogFactory.getLogger(POIFSChunkParser.class); - public static ChunkGroup[] parse(POIFSFileSystem fs) throws IOException { - return parse(fs.getRoot()); - } - public static ChunkGroup[] parse(DirectoryNode node) throws IOException { - Chunks mainChunks = new Chunks(); - - ArrayList groups = new ArrayList<>(); - groups.add(mainChunks); + private POIFSChunkParser() {} - // Find our top level children - // Note - we don't handle children of children yet, as - // there doesn't seem to be any use of that in Outlook - for(Entry entry : node) { - if(entry instanceof DirectoryNode) { - DirectoryNode dir = (DirectoryNode)entry; - ChunkGroup group = null; - - // Do we know what to do with it? - if(dir.getName().startsWith(AttachmentChunks.PREFIX)) { - group = new AttachmentChunks(dir.getName()); + public static ChunkGroup[] parse(POIFSFileSystem fs) { + return parse(fs.getRoot()); + } + + public static ChunkGroup[] parse(DirectoryNode node) { + Chunks mainChunks = new Chunks(); + + ArrayList groups = new ArrayList<>(); + groups.add(mainChunks); + + // Find our top level children + // Note - we don't handle children of children yet, as + // there doesn't seem to be any use of that in Outlook + for (Entry entry : node) { + if (entry instanceof DirectoryNode) { + DirectoryNode dir = (DirectoryNode) entry; + ChunkGroup group = null; + + // Do we know what to do with it? + if (dir.getName().startsWith(AttachmentChunks.PREFIX)) { + group = new AttachmentChunks(dir.getName()); + } + if (dir.getName().startsWith(NameIdChunks.NAME)) { + group = new NameIdChunks(); + } + if (dir.getName().startsWith(RecipientChunks.PREFIX)) { + group = new RecipientChunks(dir.getName()); + } + + if (group != null) { + processChunks(dir, group); + groups.add(group); + } } - if(dir.getName().startsWith(NameIdChunks.NAME)) { - group = new NameIdChunks(); + } + + // Now do the top level chunks + processChunks(node, mainChunks); + + // All chunks are now processed, have the ChunkGroup + // match up variable-length properties and their chunks + for (ChunkGroup group : groups) { + group.chunksComplete(); + } + + // Finish + return groups.toArray(new ChunkGroup[0]); + } + + /** + * Creates all the chunks for a given Directory, but + * doesn't recurse or descend + */ + private static void processChunks(DirectoryNode node, ChunkGroup grouping) { + final Map multiChunks = new TreeMap<>(); + + for (Entry entry : node) { + if (entry instanceof DocumentNode || + (entry instanceof DirectoryNode && entry.getName().endsWith(Types.DIRECTORY.asFileEnding()))) { + process(entry, grouping, multiChunks); } - if(dir.getName().startsWith(RecipientChunks.PREFIX)) { - group = new RecipientChunks(dir.getName()); + } + + // Finish up variable length multivalued properties + multiChunks.entrySet().stream() + .flatMap(me -> me.getValue().getChunks().values().stream()) + .filter(Objects::nonNull) + .forEach(grouping::record); + } + + /** + * Creates a chunk, and gives it to its parent group + */ + private static void process(Entry entry, ChunkGroup grouping, Map multiChunks) { + final String entryName = entry.getName(); + boolean[] isMultiValued = { false }; + + // Is it a properties chunk? (They have special names) + Chunk chunk = (PropertiesChunk.NAME.equals(entryName)) + ? readPropertiesChunk(grouping, entry) + : readPrimitiveChunk(entry, isMultiValued, multiChunks); + + if (chunk == null) { + return; + } + + if (entry instanceof DocumentNode) { + try (DocumentInputStream inp = new DocumentInputStream((DocumentNode) entry)) { + chunk.readValue(inp); + } catch (IOException e) { + LOG.log(POILogger.ERROR, "Error reading from part " + entry.getName(), e); } - - if(group != null) { - processChunks(dir, group); - groups.add(group); - } else { - // Unknown directory, skip silently - } - } - } - - // Now do the top level chunks - processChunks(node, mainChunks); - - // All chunks are now processed, have the ChunkGroup - // match up variable-length properties and their chunks - for (ChunkGroup group : groups) { - group.chunksComplete(); - } - - // Finish - return groups.toArray(new ChunkGroup[0]); - } - - /** - * Creates all the chunks for a given Directory, but - * doesn't recurse or descend - */ - protected static void processChunks(DirectoryNode node, ChunkGroup grouping) { - for(Entry entry : node) { - if(entry instanceof DocumentNode) { - process(entry, grouping); - } else if(entry instanceof DirectoryNode) { - if(entry.getName().endsWith(Types.DIRECTORY.asFileEnding())) { - process(entry, grouping); - } - } - } - } - - /** - * Creates a chunk, and gives it to its parent group - */ - protected static void process(Entry entry, ChunkGroup grouping) { - String entryName = entry.getName(); - Chunk chunk = null; - - // Is it a properties chunk? (They have special names) - if (entryName.equals(PropertiesChunk.NAME)) { - if (grouping instanceof Chunks) { + } + + if (!isMultiValued[0]) { + // multi value chunks will be grouped later, in the correct order + grouping.record(chunk); + } + } + + private static Chunk readPropertiesChunk(ChunkGroup grouping, Entry entry) { + if (grouping instanceof Chunks) { // These should be the properties for the message itself - chunk = new MessagePropertiesChunk(grouping, - entry.getParent() != null && entry.getParent().getParent() != null); - } else { + boolean isEmbedded = entry.getParent() != null && entry.getParent().getParent() != null; + return new MessagePropertiesChunk(grouping, isEmbedded); + } else { // Will be properties on an attachment or recipient - chunk = new StoragePropertiesChunk(grouping); - } - } else { - // Check it's a regular chunk - if(entryName.length() < 9) { + return new StoragePropertiesChunk(grouping); + } + } + + private static Chunk readPrimitiveChunk(Entry entry, boolean[] isMultiValue, Map multiChunks) { + final String entryName = entry.getName(); + final int splitAt = entryName.lastIndexOf('_'); + + // Check it's a regular chunk + if (entryName.length() < 9 || splitAt == -1) { // Name in the wrong format - return; - } - if(! entryName.contains("_")) { - // Name in the wrong format - return; - } - - // Split it into its parts - int splitAt = entryName.lastIndexOf('_'); - String namePrefix = entryName.substring(0, splitAt+1); - String ids = entryName.substring(splitAt+1); - - // Make sure we got what we expected, should be of - // the form ___ - if(namePrefix.equals("Olk10SideProps") || - namePrefix.equals("Olk10SideProps_")) { + return null; + } + + // Split it into its parts + final String namePrefix = entryName.substring(0, splitAt + 1); + final String ids = entryName.substring(splitAt + 1); + + // Make sure we got what we expected, should be of + // the form ___ + if (namePrefix.equals("Olk10SideProps") || namePrefix.equals("Olk10SideProps_")) { // This is some odd Outlook 2002 thing, skip - return; - } else if(splitAt <= entryName.length()-8) { - // In the right form for a normal chunk - // We'll process this further in a little bit - } else { + return null; + } else if (splitAt > entryName.length() - 8) { // Underscores not the right place, something's wrong throw new IllegalArgumentException("Invalid chunk name " + entryName); - } - - // Now try to turn it into id + type - try { - int chunkId = Integer.parseInt(ids.substring(0, 4), 16); - int typeId = Integer.parseInt(ids.substring(4, 8), 16); - - MAPIType type = Types.getById(typeId); - if (type == null) { - type = Types.createCustom(typeId); - } - - // Special cases based on the ID - if(chunkId == MAPIProperty.MESSAGE_SUBMISSION_ID.id) { - chunk = new MessageSubmissionChunk(namePrefix, chunkId, type); - } - else { - // Nothing special about this ID - // So, do the usual thing which is by type - if (type == Types.BINARY) { - chunk = new ByteChunk(namePrefix, chunkId, type); - } - else if (type == Types.DIRECTORY) { - if(entry instanceof DirectoryNode) { - chunk = new DirectoryChunk((DirectoryNode)entry, namePrefix, chunkId, type); - } - } - else if (type == Types.ASCII_STRING || - type == Types.UNICODE_STRING) { - chunk = new StringChunk(namePrefix, chunkId, type); - } - else { - // Type of an unsupported type! Skipping... - } - } - } catch(NumberFormatException e) { + } + + // Now try to turn it into id + type + final int chunkId, typeId; + try { + chunkId = Integer.parseInt(ids.substring(0, 4), 16); + int tid = Integer.parseInt(ids.substring(4, 8), 16); + isMultiValue[0] = (tid & Types.MULTIVALUED_FLAG) != 0; + typeId = tid & ~Types.MULTIVALUED_FLAG; + } catch (NumberFormatException e) { // Name in the wrong format - return; - } - } - - if(chunk != null) { - if(entry instanceof DocumentNode) { - try (DocumentInputStream inp = new DocumentInputStream((DocumentNode) entry)) { - chunk.readValue(inp); - grouping.record(chunk); - } catch (IOException e) { - logger.log(POILogger.ERROR, "Error reading from part " + entry.getName() + " - " + e); - } - } else { - grouping.record(chunk); - } - } - } + return null; + } + + MAPIType type = Types.getById(typeId); + if (type == null) { + type = Types.createCustom(typeId); + } + + // Special cases based on the ID + if (chunkId == MAPIProperty.MESSAGE_SUBMISSION_ID.id) { + return new MessageSubmissionChunk(namePrefix, chunkId, type); + } else if (type == Types.BINARY && chunkId == MAPIProperty.ATTACH_DATA.id) { + ByteChunkDeferred bcd = new ByteChunkDeferred(namePrefix, chunkId, type); + if (entry instanceof DocumentNode) { + bcd.readValue((DocumentNode) entry); + } + return bcd; + } else { + // Nothing special about this ID + // So, do the usual thing which is by type + if (isMultiValue[0]) { + return readMultiValue(namePrefix, ids, chunkId, entry, type, multiChunks); + } else { + if (type == Types.DIRECTORY && entry instanceof DirectoryNode) { + return new DirectoryChunk((DirectoryNode) entry, namePrefix, chunkId, type); + } else if (type == Types.BINARY) { + return new ByteChunk(namePrefix, chunkId, type); + } else if (type == Types.ASCII_STRING || type == Types.UNICODE_STRING) { + return new StringChunk(namePrefix, chunkId, type); + } + // Type of an unsupported type! Skipping... + LOG.log(POILogger.WARN, "UNSUPPORTED PROP TYPE " + entryName); + return null; + } + } + } + + + private static Chunk readMultiValue(String namePrefix, String ids, int chunkId, Entry entry, MAPIType type, + Map multiChunks) { + long multiValueIdx = -1; + if (ids.contains("-")) { + String mvidxstr = ids.substring(ids.lastIndexOf('-') + 1); + try { + multiValueIdx = Long.parseLong(mvidxstr) & 0xFFFFFFFFL; + } catch (NumberFormatException ignore) { + LOG.log(POILogger.WARN, "Can't read multi value idx from entry " + entry.getName()); + } + } + + final MultiChunk mc = multiChunks.computeIfAbsent(chunkId, k -> new MultiChunk()); + if (multiValueIdx == -1) { + return new ByteChunk(chunkId, Types.BINARY) { + @Override + public void readValue(InputStream value) throws IOException { + super.readValue(value); + mc.setLength(getValue().length / 4); + } + }; + } else { + final Chunk chunk; + if (type == Types.BINARY) { + chunk = new ByteChunk(namePrefix, chunkId, type); + } else if (type == Types.ASCII_STRING || type == Types.UNICODE_STRING) { + chunk = new StringChunk(namePrefix, chunkId, type); + } else { + // Type of an unsupported multivalued type! Skipping... + LOG.log(POILogger.WARN, "Unsupported multivalued prop type for entry " + entry.getName()); + return null; + } + mc.addChunk((int) multiValueIdx, chunk); + return chunk; + } + } + + private static class MultiChunk { + private int length = -1; + private final Map chunks = new TreeMap<>(); + + @SuppressWarnings("unused") + int getLength() { + return length; + } + + void setLength(int length) { + this.length = length; + } + + void addChunk(int multiValueIdx, Chunk value) { + chunks.put(multiValueIdx, value); + } + + Map getChunks() { + return chunks; + } + } } diff --git a/src/scratchpad/testcases/org/apache/poi/hsmf/TestFileWithAttachmentsRead.java b/src/scratchpad/testcases/org/apache/poi/hsmf/TestFileWithAttachmentsRead.java index 100c4505bc..5933e70dbf 100644 --- a/src/scratchpad/testcases/org/apache/poi/hsmf/TestFileWithAttachmentsRead.java +++ b/src/scratchpad/testcases/org/apache/poi/hsmf/TestFileWithAttachmentsRead.java @@ -18,19 +18,18 @@ package org.apache.poi.hsmf; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import java.io.ByteArrayOutputStream; import java.io.IOException; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.Test; - import org.apache.poi.POIDataSamples; import org.apache.poi.hsmf.datatypes.AttachmentChunks; -import org.apache.poi.hsmf.exceptions.ChunkNotFoundException; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; /** * Tests to verify that we can read attachments from msg file @@ -42,8 +41,6 @@ public class TestFileWithAttachmentsRead { /** * Initialize this test, load up the attachment_test_msg.msg mapi message. - * - * @throws Exception */ @BeforeClass public static void setUp() throws IOException { @@ -62,16 +59,13 @@ public class TestFileWithAttachmentsRead { /** * Test to see if we can retrieve attachments. - * - * @throws ChunkNotFoundException - * */ @Test public void testRetrieveAttachments() { // Simple file AttachmentChunks[] attachments = twoSimpleAttachments.getAttachmentFiles(); assertEquals(2, attachments.length); - + // Other file attachments = pdfMsgAttachments.getAttachmentFiles(); assertEquals(2, attachments.length); @@ -134,16 +128,24 @@ public class TestFileWithAttachmentsRead { assertEquals("test-unicode.doc", attachment.getAttachLongFileName().getValue()); assertEquals(".doc", attachment.getAttachExtension().getValue()); assertNull(attachment.getAttachMimeTag()); - assertEquals(24064, attachment.getAttachData().getValue().length); // or compare the hashes of the attachment data + ByteArrayOutputStream attachmentstream = new ByteArrayOutputStream(); + attachment.getAttachData().writeValue(attachmentstream); + assertEquals(24064, attachmentstream.size()); + // or compare the hashes of the attachment data + assertEquals(24064, attachment.getAttachData().getValue().length); attachment = twoSimpleAttachments.getAttachmentFiles()[1]; assertEquals("pj1.txt", attachment.getAttachFileName().getValue()); assertEquals("pj1.txt", attachment.getAttachLongFileName().getValue()); assertEquals(".txt", attachment.getAttachExtension().getValue()); assertNull(attachment.getAttachMimeTag()); - assertEquals(89, attachment.getAttachData().getValue().length); // or compare the hashes of the attachment data + // or compare the hashes of the attachment data + assertEquals(89, attachment.getAttachData().getValue().length); + attachmentstream = new ByteArrayOutputStream(); + attachment.getAttachData().writeValue(attachmentstream); + assertEquals(89, attachmentstream.size()); } - + /** * Test that we can handle both PDF and MSG attachments */ @@ -151,7 +153,7 @@ public class TestFileWithAttachmentsRead { public void testReadMsgAttachments() throws Exception { AttachmentChunks[] attachments = pdfMsgAttachments.getAttachmentFiles(); assertEquals(2, attachments.length); - + AttachmentChunks attachment; // Second is a PDF @@ -161,8 +163,9 @@ public class TestFileWithAttachmentsRead { assertEquals(".pdf", attachment.getAttachExtension().getValue()); assertNull(attachment.getAttachMimeTag()); assertNull(attachment.getAttachmentDirectory()); - assertEquals(13539, attachment.getAttachData().getValue().length); //or compare the hashes of the attachment data - + //or compare the hashes of the attachment data + assertEquals(13539, attachment.getAttachData().getValue().length); + // First in a nested message attachment = pdfMsgAttachments.getAttachmentFiles()[0]; assertEquals("Test Attachment", attachment.getAttachFileName().getValue()); @@ -171,7 +174,7 @@ public class TestFileWithAttachmentsRead { assertNull(attachment.getAttachMimeTag()); assertNull(attachment.getAttachData()); assertNotNull(attachment.getAttachmentDirectory()); - + // Check we can see some bits of it MAPIMessage nested = attachment.getAttachmentDirectory().getAsEmbeddedMessage(); assertEquals(1, nested.getRecipientNamesList().length); diff --git a/src/scratchpad/testcases/org/apache/poi/hsmf/TestNameIdChunks.java b/src/scratchpad/testcases/org/apache/poi/hsmf/TestNameIdChunks.java new file mode 100644 index 0000000000..125250be20 --- /dev/null +++ b/src/scratchpad/testcases/org/apache/poi/hsmf/TestNameIdChunks.java @@ -0,0 +1,89 @@ +/* ==================================================================== + 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.poi.hsmf; + +import static org.apache.poi.hsmf.datatypes.NameIdChunks.PredefinedPropertySet.PSETID_COMMON; +import static org.apache.poi.hsmf.datatypes.NameIdChunks.PropertySetType.PS_PUBLIC_STRINGS; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.io.InputStream; + +import org.apache.poi.POIDataSamples; +import org.apache.poi.hsmf.datatypes.StringChunk; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Tests to verify that we can read properties identified by name or id in property sets. + */ +public class TestNameIdChunks { + private static MAPIMessage keywordsMsg; + + /** + * Initialize this test, load up the keywords.msg mapi message. + */ + @BeforeClass + public static void setUp() throws IOException { + POIDataSamples samples = POIDataSamples.getHSMFInstance(); + try (InputStream is = samples.openResourceAsStream("keywords.msg")) { + keywordsMsg = new MAPIMessage(is); + } + } + + @AfterClass + public static void tearDown() throws IOException { + keywordsMsg.close(); + } + + /** + * Test to see if we can read the keywords list from the msg. + * The keywords property is a property identified by the name "Keywords" in the property set PS_PUBLIC_STRINGS. + */ + @Test + public void testReadKeywords() { + long keywordsPropTag = keywordsMsg.getNameIdChunks().getPropertyTag(PS_PUBLIC_STRINGS.classID, "Keywords", 0); + assertEquals(0x8003, keywordsPropTag); + String[] exp = { "TODO", "Currently Important", "Currently To Do", "Test" }; + String[] act = getValues(keywordsPropTag); + assertArrayEquals(exp, act); + } + + /** + * Test to see if we can read the current version name from the msg. + * The current version name property is a property identified by the id 0x8554 in the property set PSETID_Common. + */ + @Test + public void testCurrentVersionName() { + long testPropTag = keywordsMsg.getNameIdChunks().getPropertyTag(PSETID_COMMON.classID, null, 0x8554); + assertEquals(0x8006, testPropTag); + String[] exp = { "16.0" }; + String[] act = getValues(testPropTag); + assertArrayEquals(exp, act); + } + + private String[] getValues(long tag) { + return keywordsMsg.getMainChunks().getAll().entrySet().stream() + .filter(me -> me.getKey().id == tag) + .flatMap(me -> me.getValue().stream()) + .map(c -> ((StringChunk)c).getValue()) + .toArray(String[]::new); + } +} diff --git a/test-data/hsmf/keywords.msg b/test-data/hsmf/keywords.msg new file mode 100644 index 0000000000000000000000000000000000000000..30436b517d2e1346e0c92a8a2f8e6b388c961a0c GIT binary patch literal 21504 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 literal 0 HcmV?d00001