libextractor

GNU libextractor
Log | Files | Refs | Submodules | README | LICENSE

commit 3fc554ef0d3fdf19da5a4eccaa677cf309badcf3
parent 0ebd2b99053a069ba982b2854b6ad603aae5508d
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed, 29 Jul 2026 00:17:17 +0200

fix #2096

Diffstat:
M.gitignore | 1+
Mdoc/libextractor.texi | 3+++
Msrc/include/extractor.h | 8+++++++-
Msrc/main/extractor_metatypes.c | 10++++++++--
Msrc/plugins/Makefile.am | 23+++++++++++++++++++++++
Asrc/plugins/msoffice_biff.h | 448+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/plugins/msoffice_extractor.c | 1226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/plugins/ole2_extractor.c | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Asrc/plugins/test_msoffice.c | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/plugins/test_ole2.c | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/plugins/testdata/README | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Asrc/plugins/testdata/msoffice_biff4.xls | 0
Asrc/plugins/testdata/msoffice_excel.xlsx | 0
Asrc/plugins/testdata/msoffice_powerpoint.pptx | 0
Asrc/plugins/testdata/msoffice_review.docx | 0
Asrc/plugins/testdata/msoffice_shared.xlsx | 0
Asrc/plugins/testdata/msoffice_word.docx | 0
Asrc/plugins/testdata/ole2_powerpoint.ppt | 0
18 files changed, 2392 insertions(+), 8 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -105,6 +105,7 @@ src/plugins/test_man src/plugins/test_midi src/plugins/test_mime src/plugins/test_mpeg +src/plugins/test_msoffice src/plugins/test_nsf src/plugins/test_nsfe src/plugins/test_odf diff --git a/doc/libextractor.texi b/doc/libextractor.texi @@ -810,6 +810,9 @@ MIME (using libmagic) @item MPEG (using libmpeg2) @item +MSOFFICE (OOXML: @file{.docx}, @file{.xlsx}, @file{.pptx}; and +Excel 2-4 BIFF streams) +@item NSF @item NSFE diff --git a/src/include/extractor.h b/src/include/extractor.h @@ -404,7 +404,13 @@ enum EXTRACTOR_MetaType EXTRACTOR_METATYPE_NARINFO = 229, EXTRACTOR_METATYPE_NAR = 230, - EXTRACTOR_METATYPE_LAST = 231 + /** + * Operating system the document was last written on, as recorded in + * the PropertySetSystemIdentifier of an OLE2 property set. + */ + EXTRACTOR_METATYPE_AUTHORING_OS = 231, + + EXTRACTOR_METATYPE_LAST = 232 }; /** @} */ /* end of meta data types */ diff --git a/src/main/extractor_metatypes.c b/src/main/extractor_metatypes.c @@ -224,7 +224,8 @@ static const struct MetaTypeDescription meta_type_descriptions[] = { gettext_noop ("functionality provided by this package") }, { gettext_noop ("recommendations"), gettext_noop ( - "packages recommended for installation in conjunction with this package") }, + "packages recommended for installation in conjunction with this package")} + , { gettext_noop ("suggestions"), gettext_noop ( "packages suggested for installation in conjunction with this package") }, @@ -515,7 +516,8 @@ static const struct MetaTypeDescription meta_type_descriptions[] = { "geo elevation of where the media has been recorded or produced in meters according to WGS84 (zero is average sea level)") }, { gettext_noop ("location horizontal error"), gettext_noop ( - "represents the expected error on the horizontal positioning in meters") }, + "represents the expected error on the horizontal positioning in meters") } + , { gettext_noop ("location movement speed"), gettext_noop ( "speed of the capturing device when performing the capture. Represented in m/s") }, @@ -588,6 +590,10 @@ static const struct MetaTypeDescription meta_type_descriptions[] = { { gettext_noop ("nar"), gettext_noop ("normalized archive") }, + { gettext_noop ("authoring operating system"), + gettext_noop ( + "operating system the document was last written on") }, + { gettext_noop ("last"), gettext_noop ("last") } }; diff --git a/src/plugins/Makefile.am b/src/plugins/Makefile.am @@ -43,12 +43,19 @@ EXTRA_DIST = \ testdata/midi_dth.mid \ testdata/mpeg_alien.mpg \ testdata/mpeg_melt.mpg \ + testdata/msoffice_biff4.xls \ + testdata/msoffice_excel.xlsx \ + testdata/msoffice_powerpoint.pptx \ + testdata/msoffice_review.docx \ + testdata/msoffice_shared.xlsx \ + testdata/msoffice_word.docx \ testdata/nsf_arkanoid.nsf \ testdata/nsfe_classics.nsfe \ testdata/odf_cg.odt \ testdata/ole2_blair.doc \ testdata/ole2_excel.xls \ testdata/ole2_msword.doc \ + testdata/ole2_powerpoint.ppt \ testdata/ole2_starwriter40.sdw \ testdata/ogg_courseclear.ogg \ testdata/pdf_extract.pdf \ @@ -156,11 +163,13 @@ endif if HAVE_ZLIB PLUGIN_ZLIB= \ libextractor_deb.la \ + libextractor_msoffice.la \ libextractor_odf.la \ libextractor_png.la \ libextractor_qt.la \ libextractor_zip.la TEST_ZLIB=test_deb +TEST_MSOFFICE=test_msoffice TEST_QT=test_qt endif @@ -237,6 +246,7 @@ check_PROGRAMS = \ $(TEST_MIDI) \ $(TEST_MIME) \ $(TEST_MPEG) \ + $(TEST_MSOFFICE) \ $(TEST_OGG) \ $(TEST_PDF) \ $(TEST_QT) \ @@ -499,6 +509,19 @@ test_nsfe_LDADD = \ $(top_builddir)/src/plugins/libtest.la +libextractor_msoffice_la_SOURCES = \ + msoffice_extractor.c msoffice_biff.h +libextractor_msoffice_la_LDFLAGS = \ + $(PLUGINFLAGS) +libextractor_msoffice_la_LIBADD = \ + $(top_builddir)/src/common/libextractor_common.la $(XLIB) $(LE_LIBINTL) + +test_msoffice_SOURCES = \ + test_msoffice.c +test_msoffice_LDADD = \ + $(top_builddir)/src/plugins/libtest.la + + libextractor_odf_la_SOURCES = \ odf_extractor.c libextractor_odf_la_LDFLAGS = \ diff --git a/src/plugins/msoffice_biff.h b/src/plugins/msoffice_biff.h @@ -0,0 +1,448 @@ +/* + This file is part of libextractor. + Copyright (C) 2026 Vidyut Samanta and Christian Grothoff + + libextractor is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published + by the Free Software Foundation; either version 3, or (at your + option) any later version. + + libextractor is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + General Public License for more details. + + You should have received a copy of the GNU General Public License + along with libextractor; see the file COPYING. If not, write to the + Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. + */ +/** + * @file plugins/msoffice_biff.h + * @brief extract user names from the "File Protection Block" of an + * Excel BIFF stream + * @author Christian Grothoff + * + * Excel stores the name of the user that saved the file in the + * WRITEACCESS record, and the name of the user that write-protected + * the file in the FILESHARING record. Both are part of the "File + * Protection Block" at the very beginning of the workbook globals + * substream (section 4.19 of OpenOffice.org's documentation of the + * Microsoft Excel file format, [MS-XLS] sections 2.4.349 and 2.4.117). + * Neither is covered by the OLE2 property sets, so this information is + * missed by extractors that only look at (Document)SummaryInformation. + * + * The very same substream exists both inside the "Workbook" (BIFF8) or + * "Book" (BIFF5) stream of an OLE2 container *and* as the entire + * content of a pre-OLE2 Excel file (BIFF2-BIFF4 wrote the record + * stream straight to disk). This header is therefore shared between + * the `ole2' plugin (which has an OLE2 container to open first) and + * the `msoffice' plugin (which handles the bare streams). + */ +#ifndef MSOFFICE_BIFF_H +#define MSOFFICE_BIFF_H + +#include "platform.h" +#include "extractor.h" +#include "convert.h" + + +/** + * BIFF record identifier for FILESHARING. + */ +#define BIFF_ID_FILESHARING 0x005B + +/** + * BIFF record identifier for WRITEACCESS. + */ +#define BIFF_ID_WRITEACCESS 0x005C + +/** + * BIFF record identifier for CODEPAGE. + */ +#define BIFF_ID_CODEPAGE 0x0042 + +/** + * BIFF record identifier for EOF. + */ +#define BIFF_ID_EOF 0x000A + +/** + * Longest user name we are willing to report. The format allows for + * 109 characters; we accept a bit more to tolerate sloppy writers, but + * refuse to allocate unbounded amounts of memory. + */ +#define BIFF_MAX_NAME_LEN 1024 + +/** + * Maximum number of records we inspect before giving up. The File + * Protection Block is at the very start of the globals substream, so + * a small limit is plenty and bounds the work done on hostile input. + */ +#define BIFF_MAX_RECORDS 256 + + +/** + * Read a 16 bit little endian value. + * + * @param p buffer to read from, must have at least 2 valid bytes + * @return the value read + */ +static uint16_t +biff_le16 (const unsigned char *p) +{ + return (uint16_t) (p[0] | (p[1] << 8)); +} + + +/** + * Determine the BIFF version a stream is written in from its leading + * BOF record. + * + * @param rid record identifier of the first record + * @param data payload of the first record + * @param size number of bytes in @a data + * @return 2, 3, 4, 5 or 8, or 0 if @a rid is not a BOF record + */ +static unsigned int +biff_version_from_bof (uint16_t rid, + const unsigned char *data, + size_t size) +{ + switch (rid) + { + case 0x0009: + return 2; + case 0x0209: + return 3; + case 0x0409: + return 4; + case 0x0809: + /* BIFF5 and BIFF8 share the record identifier and are told apart + by the version field of the record. */ + if (size < 2) + return 0; + return (biff_le16 (data) >= 0x0600) ? 8 : 5; + default: + return 0; + } +} + + +/** + * Map the value of a CODEPAGE record to an iconv character set name. + * + * @param cv value of the CODEPAGE record + * @return name of the character set, NULL if unknown + */ +static const char * +biff_codepage_to_charset (uint16_t cv) +{ + switch (cv) + { + case 367: + return "ASCII"; + case 437: + return "CP437"; + case 737: + return "CP737"; + case 775: + return "CP775"; + case 850: + return "CP850"; + case 852: + return "CP852"; + case 855: + return "CP855"; + case 857: + return "CP857"; + case 860: + return "CP860"; + case 861: + return "CP861"; + case 862: + return "CP862"; + case 863: + return "CP863"; + case 864: + return "CP864"; + case 865: + return "CP865"; + case 866: + return "CP866"; + case 869: + return "CP869"; + case 874: + return "CP874"; + case 932: + return "CP932"; + case 936: + return "CP936"; + case 949: + return "CP949"; + case 950: + return "CP950"; + case 1200: + return "UTF-16LE"; + case 1250: + return "CP1250"; + case 1251: + return "CP1251"; + case 32769: /* BIFF2-BIFF3 wrote this for "ANSI" */ + case 1252: + return "CP1252"; + case 1253: + return "CP1253"; + case 1254: + return "CP1254"; + case 1255: + return "CP1255"; + case 1256: + return "CP1256"; + case 1257: + return "CP1257"; + case 1258: + return "CP1258"; + case 10000: + case 32768: + return "MACINTOSH"; + default: + return NULL; + } +} + + +/** + * Strip trailing spaces (used as padding by WRITEACCESS) and leading + * and trailing white space from @a s, in place. + * + * @param s 0-terminated string to trim + * @return pointer into @a s to the first non-blank character + */ +static char * +biff_trim (char *s) +{ + size_t len = strlen (s); + + while ( (0 < len) && + (isspace ((unsigned char) s[len - 1])) ) + s[--len] = '\0'; + while (isspace ((unsigned char) s[0])) + s++; + return s; +} + + +/** + * Decode a BIFF string into UTF-8. + * + * For BIFF2-BIFF5 this is a "byte string with 8-bit string length" + * (section 2.5.2): one length byte followed by that many characters in + * the workbook's code page. For BIFF8 it is a "Unicode string with + * 16-bit string length" (section 2.5.3): a 16 bit character count, a + * flags byte and then either 8 bit (compressed) or 16 bit characters, + * possibly preceded by rich text and phonetic size fields. + * + * @param data record payload + * @param size number of bytes in @a data + * @param off offset of the string within @a data + * @param biff BIFF version of the stream + * @param charset character set to assume for 8 bit characters + * @return UTF-8 string to be freed by the caller, NULL on error + */ +static char * +biff_decode_string (const unsigned char *data, + size_t size, + size_t off, + unsigned int biff, + const char *charset) +{ + size_t cch; + size_t need; + + if (off >= size) + return NULL; + if (8 > biff) + { + cch = data[off++]; + if ( (0 == cch) || + (BIFF_MAX_NAME_LEN < cch) || + (off + cch > size) ) + return NULL; + return EXTRACTOR_common_convert_to_utf8 ((const char *) &data[off], + cch, + charset); + } + if (off + 3 > size) + return NULL; + cch = biff_le16 (&data[off]); + { + unsigned char flags = data[off + 2]; + + off += 3; + if (0 != (flags & 0x08)) + off += 2; /* cRun of a rich text string */ + if (0 != (flags & 0x04)) + off += 4; /* cbExtRst of an Asian phonetic string */ + if ( (0 == cch) || + (BIFF_MAX_NAME_LEN < cch) ) + return NULL; + need = (0 != (flags & 0x01)) ? 2 * cch : cch; + if ( (off > size) || + (off + need > size) ) + return NULL; + return EXTRACTOR_common_convert_to_utf8 ((const char *) &data[off], + need, + (0 != (flags & 0x01)) + ? "UTF-16LE" + : charset); + } +} + + +/** + * Determine the character set of a BIFF stream by locating its + * CODEPAGE record. The File Protection Block precedes CODEPAGE in the + * stream, so we have to look ahead before we can decode any of its + * strings. + * + * @param data the BIFF stream + * @param size number of bytes in @a data + * @return name of the character set, never NULL + */ +static const char * +biff_find_charset (const unsigned char *data, + size_t size) +{ + size_t off = 0; + unsigned int i; + + for (i = 0; i < BIFF_MAX_RECORDS; i++) + { + uint16_t rid; + uint16_t len; + + if (off + 4 > size) + break; + rid = biff_le16 (&data[off]); + len = biff_le16 (&data[off + 2]); + if (off + 4 + (size_t) len > size) + break; + if (BIFF_ID_CODEPAGE == rid) + { + const char *cs; + + if ( (2 <= len) && + (NULL != (cs = biff_codepage_to_charset (biff_le16 (&data[off + + 4])))) ) + return cs; + break; + } + if ( (BIFF_ID_EOF == rid) && + (0 < i) ) + break; + off += 4 + (size_t) len; + } + return "CP1252"; +} + + +/** + * Scan the globals substream of a BIFF stream and report the user + * names found in its File Protection Block. + * + * @param data the BIFF stream, starting at its BOF record + * @param size number of bytes in @a data + * @param plugin_name name to report to @a proc as the source plugin + * @param proc function to call on meta data found + * @param proc_cls closure for @a proc + * @return 0 to continue extracting, 1 if @a proc asked us to stop, + * -1 if @a data does not start with a BOF record + */ +static int +EXTRACTOR_msoffice_biff_extract (const unsigned char *data, + size_t size, + const char *plugin_name, + EXTRACTOR_MetaDataProcessor proc, + void *proc_cls) +{ + const char *charset; + size_t off = 0; + unsigned int biff; + unsigned int i; + int ret = 0; + + if (4 > size) + return -1; + biff = biff_version_from_bof (biff_le16 (data), + &data[4], + (size - 4 < (size_t) biff_le16 (&data[2])) + ? size - 4 + : (size_t) biff_le16 (&data[2])); + if (0 == biff) + return -1; + charset = (8 == biff) ? "CP1252" : biff_find_charset (data, size); + for (i = 0; i < BIFF_MAX_RECORDS; i++) + { + uint16_t rid; + uint16_t len; + const unsigned char *rec; + char *name = NULL; + enum EXTRACTOR_MetaType type; + + if (off + 4 > size) + break; + rid = biff_le16 (&data[off]); + len = biff_le16 (&data[off + 2]); + if (off + 4 + (size_t) len > size) + break; + rec = &data[off + 4]; + off += 4 + (size_t) len; + if ( (BIFF_ID_EOF == rid) && + (0 < i) ) + break; /* end of the globals substream */ + switch (rid) + { + case BIFF_ID_WRITEACCESS: + /* The user name that saved the file. Note that the record is + padded with spaces to a fixed size, and that the size differs + between BIFF versions (and between writers). */ + name = biff_decode_string (rec, len, 0, biff, charset); + type = EXTRACTOR_METATYPE_LAST_SAVED_BY; + break; + case BIFF_ID_FILESHARING: + /* The user name that write-protected the file, preceded by the + read-only recommendation flag and the password hash. */ + if (5 > len) + break; + name = biff_decode_string (rec, len, 4, biff, charset); + type = EXTRACTOR_METATYPE_CREATOR; + break; + default: + break; + } + if (NULL == name) + continue; + { + char *trimmed = biff_trim (name); + + if ( ('\0' != trimmed[0]) && + (0 != proc (proc_cls, + plugin_name, + type, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + trimmed, + strlen (trimmed) + 1)) ) + ret = 1; + } + free (name); + if (0 != ret) + break; + } + return ret; +} + + +#endif +/* end of msoffice_biff.h */ diff --git a/src/plugins/msoffice_extractor.c b/src/plugins/msoffice_extractor.c @@ -0,0 +1,1226 @@ +/* + This file is part of libextractor. + Copyright (C) 2026 Vidyut Samanta and Christian Grothoff + + libextractor is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published + by the Free Software Foundation; either version 3, or (at your + option) any later version. + + libextractor is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + General Public License for more details. + + You should have received a copy of the GNU General Public License + along with libextractor; see the file COPYING. If not, write to the + Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. + */ +/** + * @file plugins/msoffice_extractor.c + * @brief plugin to support Microsoft Office documents that are not + * stored in an OLE2 container + * @author Christian Grothoff + * + * The `ole2' plugin covers the OLE2-based generation of Microsoft + * Office formats (.doc, .xls, .ppt) and needs libgsf to open the + * container. This plugin covers the two families that live outside of + * an OLE2 container and hence need no libgsf at all: + * + * - Office Open XML (.docx, .xlsx, .pptx and their macro-enabled and + * template variants), which is a ZIP archive of XML parts. + * - Bare BIFF record streams, which is how Excel 2 to Excel 4 wrote + * .xls files before OLE2 existed. These are still common. + * + * Beyond the document summary, both carry a considerable amount of + * information about *who* edited a document and *when*: tracked + * changes, comment authors, the identities used to author them, + * shared-workbook revision logs, the "send for review" e-mail + * addresses left behind by Outlook, and the user name recorded in the + * Excel file protection records. Extracting this is the point of + * https://bugs.gnunet.org/view.php?id=2096 -- it matters both for + * forensics and for privacy tools that want to warn about the + * information a document leaks. + */ +#include "platform.h" +#include <ctype.h> +#include "extractor.h" +#include "convert.h" +#include "unzip.h" +#include "msoffice_biff.h" + + +/** + * Name this plugin reports itself as. + */ +#define PLUGIN_NAME "msoffice" + +/** + * Maximum length of a file name inside the ZIP archive. + */ +#define MAXFILENAME 256 + +/** + * Maximum number of interesting ZIP entries we remember. Documents + * with more comment or revision parts than this are pathological. + */ +#define MAX_PARTS 64 + +/** + * Maximum size of a part that we read into memory as a whole. Only + * used for the small property parts. + */ +#define MAX_SMALL_PART (256 * 1024) + +/** + * Size of the sliding window used to scan the large parts. + */ +#define SCAN_CHUNK (128 * 1024) + +/** + * Longest XML tag we are able to look at in one piece. Tags carrying + * an author are far shorter; anything longer is skipped rather than + * buffered, which bounds our memory use on hostile input. + */ +#define MAX_TAG 8192 + +/** + * Maximum number of bytes we scan in a single part. Guards against + * decompression bombs. + */ +#define MAX_SCAN_BYTES (64 * 1024 * 1024) + +/** + * Maximum number of distinct strings we remember to suppress + * duplicates. A document with tracked changes typically repeats the + * same handful of authors thousands of times. + */ +#define MAX_SEEN 256 + +/** + * Longest metadata value we report. + */ +#define MAX_VALUE 4096 + + +/** + * State kept while extracting from one document. + */ +struct MsoContext +{ + /** + * Extraction context we were called with. + */ + struct EXTRACTOR_ExtractContext *ec; + + /** + * Strings we have already reported, to suppress duplicates. + */ + char *seen[MAX_SEEN]; + + /** + * Number of used entries in @e seen. + */ + unsigned int seen_len; + + /** + * Set to 1 once the caller asked us to stop. + */ + int stop; +}; + + +/** + * Entry in a map from XML element names to LE meta data types. + */ +struct Matches +{ + /** + * Name of the XML element. + */ + const char *text; + + /** + * Corresponding LE type. + */ + enum EXTRACTOR_MetaType type; +}; + + +/** + * Elements of the OPC core properties part (`docProps/core.xml'), + * which is the Dublin Core derived summary of the document. + */ +static struct Matches core_map[] = { + { "dc:title", EXTRACTOR_METATYPE_TITLE }, + { "dc:subject", EXTRACTOR_METATYPE_SUBJECT }, + { "dc:creator", EXTRACTOR_METATYPE_CREATOR }, + { "dc:description", EXTRACTOR_METATYPE_DESCRIPTION }, + { "dc:language", EXTRACTOR_METATYPE_LANGUAGE }, + { "cp:keywords", EXTRACTOR_METATYPE_KEYWORDS }, + { "cp:category", EXTRACTOR_METATYPE_SECTION }, + { "cp:lastModifiedBy", EXTRACTOR_METATYPE_LAST_SAVED_BY }, + { "cp:revision", EXTRACTOR_METATYPE_REVISION_NUMBER }, + { "cp:lastPrinted", EXTRACTOR_METATYPE_LAST_PRINTED }, + { "dcterms:created", EXTRACTOR_METATYPE_CREATION_DATE }, + { "dcterms:modified", EXTRACTOR_METATYPE_MODIFICATION_DATE }, + { NULL, 0 } +}; + + +/** + * Elements of the extended properties part (`docProps/app.xml'), + * which is written by the application that saved the document. + */ +static struct Matches app_map[] = { + { "Application", EXTRACTOR_METATYPE_CREATED_BY_SOFTWARE }, + { "AppVersion", EXTRACTOR_METATYPE_SOFTWARE_VERSION }, + { "Company", EXTRACTOR_METATYPE_COMPANY }, + { "Manager", EXTRACTOR_METATYPE_MANAGER }, + { "Template", EXTRACTOR_METATYPE_TEMPLATE }, + { "TotalTime", EXTRACTOR_METATYPE_TOTAL_EDITING_TIME }, + { "Pages", EXTRACTOR_METATYPE_PAGE_COUNT }, + { "Words", EXTRACTOR_METATYPE_WORD_COUNT }, + { "Characters", EXTRACTOR_METATYPE_CHARACTER_COUNT }, + { "Lines", EXTRACTOR_METATYPE_LINE_COUNT }, + { "Paragraphs", EXTRACTOR_METATYPE_PARAGRAPH_COUNT }, + { NULL, 0 } +}; + + +/** + * Custom properties (`docProps/custom.xml') that carry personal + * information. Word writes these when a document is sent around for + * review from Outlook; they routinely outlive the review and identify + * the person who circulated the document. + */ +static struct Matches custom_map[] = { + { "_AuthorEmail", EXTRACTOR_METATYPE_AUTHOR_EMAIL }, + { "_AuthorEmailDisplayName", EXTRACTOR_METATYPE_AUTHOR_NAME }, + { "_EmailSubject", EXTRACTOR_METATYPE_SUBJECT }, + { NULL, 0 } +}; + + +/* ******************** generic helpers ******************** */ + + +/** + * Trim leading and trailing white space in @a s, in place. + * + * @param s 0-terminated string to trim + * @return pointer into @a s to the first non-blank character + */ +static char * +trim (char *s) +{ + size_t len = strlen (s); + + while ( (0 < len) && + (isspace ((unsigned char) s[len - 1])) ) + s[--len] = '\0'; + while (isspace ((unsigned char) s[0])) + s++; + return s; +} + + +/** + * Check whether @a value was reported before, and remember it if not. + * + * @param mc our extraction state + * @param value string to check + * @return 1 if @a value is new (and was remembered), 0 if it is a + * duplicate or if we ran out of space to remember it + */ +static int +mark_seen (struct MsoContext *mc, + const char *value) +{ + unsigned int i; + char *dup; + + for (i = 0; i < mc->seen_len; i++) + if (0 == strcmp (mc->seen[i], value)) + return 0; + if (MAX_SEEN == mc->seen_len) + return 0; + if (NULL == (dup = strdup (value))) + return 0; + mc->seen[mc->seen_len++] = dup; + return 1; +} + + +/** + * Report a meta data value, unless it is empty or a duplicate. + * + * @param mc our extraction state + * @param type meta data type to report the value as + * @param value the value; leading and trailing white space is removed + */ +static void +add_meta (struct MsoContext *mc, + enum EXTRACTOR_MetaType type, + const char *value) +{ + char *tmp; + char *val; + char key[64]; + + if ( (0 != mc->stop) || + (NULL == value) ) + return; + if (MAX_VALUE < strlen (value)) + return; + if (NULL == (tmp = strdup (value))) + return; + val = trim (tmp); + if ('\0' == val[0]) + { + free (tmp); + return; + } + /* De-duplicate per type: the same name legitimately shows up as both + a creator and a comment author, and both are worth reporting. */ + if ( (0 < snprintf (key, sizeof (key), "%d:", (int) type)) && + (strlen (key) + strlen (val) < MAX_VALUE) ) + { + char full[MAX_VALUE + 64]; + + snprintf (full, sizeof (full), "%d:%s", (int) type, val); + if (0 == mark_seen (mc, full)) + { + free (tmp); + return; + } + } + if (0 != mc->ec->proc (mc->ec->cls, + PLUGIN_NAME, + type, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + val, + strlen (val) + 1)) + mc->stop = 1; + free (tmp); +} + + +/* ******************** minimal XML scanning ******************** */ + + +/** + * Append the UTF-8 encoding of @a cp to @a out at @a *off. + * + * @param out buffer to write to + * @param off offset into @a out, updated + * @param cp code point to encode + */ +static void +append_utf8 (char *out, + size_t *off, + unsigned int cp) +{ + size_t o = *off; + + if (cp < 0x80) + { + out[o++] = (char) cp; + } + else if (cp < 0x800) + { + out[o++] = (char) (0xC0 | (cp >> 6)); + out[o++] = (char) (0x80 | (cp & 0x3F)); + } + else if (cp < 0x10000) + { + out[o++] = (char) (0xE0 | (cp >> 12)); + out[o++] = (char) (0x80 | ((cp >> 6) & 0x3F)); + out[o++] = (char) (0x80 | (cp & 0x3F)); + } + else + { + out[o++] = (char) (0xF0 | (cp >> 18)); + out[o++] = (char) (0x80 | ((cp >> 12) & 0x3F)); + out[o++] = (char) (0x80 | ((cp >> 6) & 0x3F)); + out[o++] = (char) (0x80 | (cp & 0x3F)); + } + *off = o; +} + + +/** + * Resolve the XML entity starting at @a in (which points at the `&'). + * + * @param in start of the entity + * @param len number of bytes available at @a in + * @param out buffer to append the replacement text to + * @param off offset into @a out, updated on success + * @return number of bytes consumed from @a in, 0 if this is not an + * entity reference we understand + */ +static size_t +resolve_entity (const char *in, + size_t len, + char *out, + size_t *off) +{ + static const struct + { + const char *name; + char value; + } named[] = { + { "amp;", '&' }, + { "lt;", '<' }, + { "gt;", '>' }, + { "quot;", '"' }, + { "apos;", '\'' }, + { NULL, 0 } + }; + unsigned int i; + unsigned long cp; + size_t n; + + for (i = 0; NULL != named[i].name; i++) + { + size_t nl = strlen (named[i].name); + + if ( (len > nl) && + (0 == strncmp (in + 1, named[i].name, nl)) ) + { + out[(*off)++] = named[i].value; + return nl + 1; + } + } + if ( (2 < len) && + ('#' == in[1]) ) + { + char *end; + + if ( ('x' == in[2]) || + ('X' == in[2]) ) + cp = strtoul (in + 3, &end, 16); + else + cp = strtoul (in + 2, &end, 10); + n = (size_t) (end - in); + if ( (end > in + 2) && + (n < len) && + (';' == *end) && + (0 < cp) && + (0x10FFFF >= cp) ) + { + append_utf8 (out, off, (unsigned int) cp); + return n + 1; + } + } + return 0; +} + + +/** + * Copy @a len bytes from @a in to a fresh 0-terminated string, + * resolving XML entity references on the way. OOXML parts are UTF-8, + * so no character set conversion is needed. + * + * @param in text to unescape + * @param len number of bytes in @a in + * @return the unescaped string to be freed by the caller, NULL on + * error or if @a len exceeds what we are willing to report + */ +static char * +xml_unescape (const char *in, + size_t len) +{ + char *out; + size_t i = 0; + size_t o = 0; + + if (MAX_VALUE < len) + return NULL; + /* A resolved numeric reference is never longer than its source. */ + if (NULL == (out = malloc (len + 1))) + return NULL; + while (i < len) + { + if ('&' == in[i]) + { + size_t used = resolve_entity (&in[i], len - i, out, &o); + + if (0 != used) + { + i += used; + continue; + } + } + out[o++] = in[i++]; + } + out[o] = '\0'; + return out; +} + + +/** + * Locate the value of attribute @a attr inside the XML tag @a tag. + * + * @param tag start of the tag (at the `<') + * @param len length of the tag, including the `>' + * @param attr name of the attribute to look for + * @return the unescaped attribute value to be freed by the caller, + * NULL if the attribute is not present + */ +static char * +tag_attr (const char *tag, + size_t len, + const char *attr) +{ + size_t alen = strlen (attr); + size_t i; + + for (i = 1; i + alen + 2 < len; i++) + { + const char *q; + const char *end; + + /* The attribute name must be preceded by white space, so that + looking for "name" does not match "displayName". */ + if (! isspace ((unsigned char) tag[i - 1])) + continue; + if (0 != strncmp (&tag[i], attr, alen)) + continue; + q = &tag[i + alen]; + while ( (q < tag + len) && + (isspace ((unsigned char) *q)) ) + q++; + if ( (q >= tag + len) || + ('=' != *q) ) + continue; + q++; + while ( (q < tag + len) && + (isspace ((unsigned char) *q)) ) + q++; + if ( (q >= tag + len) || + ( ('"' != *q) && + ('\'' != *q) ) ) + continue; + end = memchr (q + 1, *q, (size_t) (tag + len - q - 1)); + if (NULL == end) + return NULL; + return xml_unescape (q + 1, (size_t) (end - q - 1)); + } + return NULL; +} + + +/** + * Return the value of the first attribute from @a attrs that is + * present in @a tag. + * + * @param tag start of the tag (at the `<') + * @param len length of the tag, including the `>' + * @param attrs NULL-terminated array of attribute names, in order of + * preference + * @return the unescaped attribute value to be freed by the caller, + * NULL if none of the attributes is present + */ +static char * +tag_attr_any (const char *tag, + size_t len, + const char **attrs) +{ + unsigned int i; + + for (i = 0; NULL != attrs[i]; i++) + { + char *v = tag_attr (tag, len, attrs[i]); + + if (NULL != v) + return v; + } + return NULL; +} + + +/** + * Check whether the local name of the element in @a tag equals + * @a name, ignoring any XML namespace prefix and case. + * + * @param tag start of the tag (at the `<') + * @param len length of the tag, including the `>' + * @param name local element name to compare against + * @return 1 on match, 0 otherwise + */ +static int +tag_local_name_is (const char *tag, + size_t len, + const char *name) +{ + size_t start = 1; + size_t i; + size_t nlen = strlen (name); + + for (i = 1; i < len; i++) + { + if (':' == tag[i]) + start = i + 1; + if ( (isspace ((unsigned char) tag[i])) || + ('/' == tag[i]) || + ('>' == tag[i]) ) + break; + } + if (i - start != nlen) + return 0; + for (i = 0; i < nlen; i++) + if (tolower ((unsigned char) tag[start + i]) != tolower ((unsigned char) + name[i])) + return 0; + return 1; +} + + +/** + * Extract the text content of every occurrence of element @a name in + * @a buf and report it as @a type. + * + * @param mc our extraction state + * @param buf 0-terminated XML document + * @param name element name to look for, including any namespace prefix + * @param type meta data type to report the content as + */ +static void +report_element (struct MsoContext *mc, + const char *buf, + const char *name, + enum EXTRACTOR_MetaType type) +{ + char open[128]; + const char *p = buf; + size_t nlen = strlen (name); + + if (sizeof (open) - 2 <= nlen) + return; + open[0] = '<'; + memcpy (&open[1], name, nlen + 1); + while (0 == mc->stop) + { + const char *start; + const char *gt; + const char *close; + char *value; + + if (NULL == (p = strstr (p, open))) + return; + start = p + 1 + nlen; + /* The match must end the element name, not just prefix it. */ + if ( ('>' != start[0]) && + ('/' != start[0]) && + (! isspace ((unsigned char) start[0])) ) + { + p = start; + continue; + } + if (NULL == (gt = strchr (start, '>'))) + return; + p = gt + 1; + if ('/' == gt[-1]) + continue; /* empty element */ + if (NULL == (close = strstr (p, "</"))) + return; + if (NULL != (value = xml_unescape (p, (size_t) (close - p)))) + { + add_meta (mc, type, value); + free (value); + } + p = close; + } +} + + +/** + * Report the value of the custom document property @a name, if + * present. A custom property looks like + * `<property ... name="_AuthorEmail"><vt:lpwstr>a@b</vt:lpwstr></property>'. + * + * @param mc our extraction state + * @param buf 0-terminated XML document + * @param name value of the `name' attribute to look for + * @param type meta data type to report the value as + */ +static void +report_custom_property (struct MsoContext *mc, + const char *buf, + const char *name, + enum EXTRACTOR_MetaType type) +{ + char needle[128]; + const char *p; + const char *gt; + const char *lt; + char *value; + + if (sizeof (needle) <= strlen (name) + 8) + return; + snprintf (needle, sizeof (needle), "name=\"%s\"", name); + if (NULL == (p = strstr (buf, needle))) + return; + /* Skip to the end of the <property> tag, then into the typed value + element that follows. */ + if (NULL == (gt = strchr (p, '>'))) + return; + if (NULL == (p = strchr (gt + 1, '<'))) + return; + if (NULL == (gt = strchr (p, '>'))) + return; + if ('/' == gt[-1]) + return; + if (NULL == (lt = strchr (gt + 1, '<'))) + return; + if (NULL != (value = xml_unescape (gt + 1, (size_t) (lt - gt - 1)))) + { + add_meta (mc, type, value); + free (value); + } +} + + +/* ******************** who edited the document ******************** */ + + +/** + * Attributes that carry the name of a person, in order of preference. + * `w:author' is used by Word for tracked changes and comments, + * `w15:author' by the Word 2013 people part, `userName' by the Excel + * shared workbook revision log and `displayName' by the Excel persons + * part. + */ +static const char *author_attrs[] = { + "w:author", + "w15:author", + "userName", + "displayName", + NULL +}; + + +/** + * Attributes that carry the time of an edit. + */ +static const char *date_attrs[] = { + "w:date", + "w15:date", + "dateTime", + "p:dt", + NULL +}; + + +/** + * Attributes that carry a directory identity -- an Active Directory + * SID or, more often, the e-mail address the editor was signed in as. + */ +static const char *userid_attrs[] = { + "w15:userId", + "userId", + NULL +}; + + +/** + * Inspect one XML tag for information about who edited the document. + * + * This deliberately keys off the attributes rather than off a list of + * element names: Word alone marks up revisions with more than a dozen + * different elements (`w:ins', `w:del', `w:moveFrom', `w:rPrChange', + * `w:tcPrChange', ...), and they all carry the author and date the + * same way. + * + * @param mc our extraction state + * @param tag start of the tag (at the `<') + * @param len length of the tag, including the `>' + */ +static void +handle_tag (struct MsoContext *mc, + const char *tag, + size_t len) +{ + char *author; + char *date; + char *userid; + + if ( (2 > len) || + ('/' == tag[1]) || + ('?' == tag[1]) || + ('!' == tag[1]) ) + return; + author = tag_attr_any (tag, len, author_attrs); + if (NULL == author) + { + /* PowerPoint keeps its comment authors in a list of <p:cmAuthor> + (and, since PowerPoint 2016, <p188:author>) elements that name + the person in a plain `name' attribute. */ + if ( (tag_local_name_is (tag, len, "cmAuthor")) || + (tag_local_name_is (tag, len, "author")) || + (tag_local_name_is (tag, len, "person")) ) + author = tag_attr (tag, len, "name"); + } + userid = tag_attr_any (tag, len, userid_attrs); + if ( (NULL == author) && + (NULL == userid) ) + return; + date = tag_attr_any (tag, len, date_attrs); + if (NULL != author) + add_meta (mc, EXTRACTOR_METATYPE_CONTRIBUTOR_NAME, author); + if (NULL != userid) + add_meta (mc, EXTRACTOR_METATYPE_CONTACT_INFORMATION, userid); + if ( (NULL != author) && + (NULL != date) ) + { + char *line; + size_t bsize = strlen (author) + strlen (date) + 128; + + if (NULL != (line = malloc (bsize))) + { + int sret = snprintf (line, + bsize, + _ ("Author `%s' edited the document on `%s'"), + author, + date); + + if ( (0 < sret) && + (bsize > (size_t) sret) ) + add_meta (mc, EXTRACTOR_METATYPE_REVISION_HISTORY, line); + free (line); + } + } + free (author); + free (date); + free (userid); +} + + +/** + * Scan a buffer of XML for tags and hand each of them to + * #handle_tag(). + * + * @param mc our extraction state + * @param buf buffer to scan + * @param len number of bytes in @a buf + * @return number of bytes consumed; the remainder is an incomplete tag + * that the caller should present again with more data + */ +static size_t +scan_tags (struct MsoContext *mc, + const char *buf, + size_t len) +{ + const char *p = buf; + const char *end = buf + len; + + while (p < end) + { + const char *lt; + const char *gt; + + if (NULL == (lt = memchr (p, '<', (size_t) (end - p)))) + return len; + if (NULL == (gt = memchr (lt, '>', (size_t) (end - lt)))) + return (size_t) (lt - buf); + handle_tag (mc, lt, (size_t) (gt - lt + 1)); + if (0 != mc->stop) + return len; + p = gt + 1; + } + return len; +} + + +/* ******************** reading ZIP parts ******************** */ + + +/** + * Read the currently selected ZIP entry into a fresh 0-terminated + * buffer. + * + * @param uf the ZIP archive + * @param max maximum number of bytes to read + * @param size set to the number of bytes read + * @return the buffer to be freed by the caller, NULL on error + */ +static char * +read_current (struct EXTRACTOR_UnzipFile *uf, + size_t max, + size_t *size) +{ + struct EXTRACTOR_UnzipFileInfo fi; + char *buf; + ssize_t got; + size_t want; + + if (EXTRACTOR_UNZIP_OK != + EXTRACTOR_common_unzip_get_current_file_info (uf, &fi, NULL, 0, + NULL, 0, NULL, 0)) + return NULL; + want = fi.uncompressed_size; + if (want > max) + want = max; + if (0 == want) + return NULL; + if (EXTRACTOR_UNZIP_OK != + EXTRACTOR_common_unzip_open_current_file (uf)) + return NULL; + if (NULL == (buf = malloc (want + 1))) + { + EXTRACTOR_common_unzip_close_current_file (uf); + return NULL; + } + got = EXTRACTOR_common_unzip_read_current_file (uf, buf, want); + EXTRACTOR_common_unzip_close_current_file (uf); + if (0 >= got) + { + free (buf); + return NULL; + } + buf[got] = '\0'; + *size = (size_t) got; + return buf; +} + + +/** + * Read the part @a name into memory and return it 0-terminated. + * + * @param uf the ZIP archive + * @param name name of the part + * @return the buffer to be freed by the caller, NULL if the part is + * absent, empty or too large + */ +static char * +read_part (struct EXTRACTOR_UnzipFile *uf, + const char *name) +{ + size_t size; + + if (EXTRACTOR_UNZIP_OK != + EXTRACTOR_common_unzip_go_find_local_file (uf, name, 2)) + return NULL; + return read_current (uf, MAX_SMALL_PART, &size); +} + + +/** + * Scan the part @a name for tags describing who edited the document. + * The part is read through a sliding window, so that a part far too + * large to hold in memory (`word/document.xml' of a heavily revised + * document, or a decompression bomb) costs us a bounded amount of + * memory. + * + * @param mc our extraction state + * @param uf the ZIP archive + * @param name name of the part to scan + */ +static void +scan_part (struct MsoContext *mc, + struct EXTRACTOR_UnzipFile *uf, + const char *name) +{ + char *buf; + size_t carry = 0; + uint64_t total = 0; + + if (EXTRACTOR_UNZIP_OK != + EXTRACTOR_common_unzip_go_find_local_file (uf, name, 2)) + return; + if (EXTRACTOR_UNZIP_OK != + EXTRACTOR_common_unzip_open_current_file (uf)) + return; + /* One byte of slack so that the window is always 0-terminated: the + attribute values we hand to xml_unescape() point into it. */ + if (NULL == (buf = malloc (SCAN_CHUNK + 1))) + { + EXTRACTOR_common_unzip_close_current_file (uf); + return; + } + while (0 == mc->stop) + { + ssize_t got; + size_t have; + size_t used; + + got = EXTRACTOR_common_unzip_read_current_file (uf, + &buf[carry], + SCAN_CHUNK - carry); + if (0 >= got) + break; + have = carry + (size_t) got; + total += (uint64_t) got; + buf[have] = '\0'; + used = scan_tags (mc, buf, have); + carry = have - used; + if (MAX_TAG < carry) + carry = 0; /* absurdly long tag; resynchronise */ + if (0 != carry) + memmove (buf, &buf[used], carry); + if (MAX_SCAN_BYTES < total) + break; + } + free (buf); + EXTRACTOR_common_unzip_close_current_file (uf); +} + + +/** + * Does @a name start with @a prefix? + * + * @param name string to test + * @param prefix prefix to test for + * @return 1 on match, 0 otherwise + */ +static int +has_prefix (const char *name, + const char *prefix) +{ + return 0 == strncmp (name, prefix, strlen (prefix)); +} + + +/** + * Does @a name end with @a suffix? + * + * @param name string to test + * @param suffix suffix to test for + * @return 1 on match, 0 otherwise + */ +static int +has_suffix (const char *name, + const char *suffix) +{ + size_t nl = strlen (name); + size_t sl = strlen (suffix); + + return (nl >= sl) && (0 == strcmp (&name[nl - sl], suffix)); +} + + +/** + * Is @a name a part that can name the people who worked on the + * document? + * + * @param name name of a part inside the OOXML package + * @return 1 if the part is worth scanning, 0 otherwise + */ +static int +is_people_part (const char *name) +{ + if (! has_suffix (name, ".xml")) + return 0; + /* Word: tracked changes live in the document body and in every + story around it; comments and the identities behind them live in + parts of their own. */ + if ( (0 == strcmp (name, "word/document.xml")) || + (0 == strcmp (name, "word/footnotes.xml")) || + (0 == strcmp (name, "word/endnotes.xml")) || + (0 == strcmp (name, "word/comments.xml")) || + (0 == strcmp (name, "word/commentsExtended.xml")) || + (0 == strcmp (name, "word/people.xml")) || + (has_prefix (name, "word/header")) || + (has_prefix (name, "word/footer")) ) + return 1; + /* Excel: the workbook part carries <fileSharing userName="...">, + the direct descendant of the BIFF FILESHARING record. */ + if (0 == strcmp (name, "xl/workbook.xml")) + return 1; + /* Excel: the shared workbook revision log, the modern threaded + comment authors and the classic comment author list. */ + if ( (has_prefix (name, "xl/revisions/")) || + (has_prefix (name, "xl/persons/")) || + (has_prefix (name, "xl/threadedComments/")) || + (has_prefix (name, "xl/comments")) ) + return 1; + /* PowerPoint: the comment author list and the comments themselves. */ + if ( (0 == strcmp (name, "ppt/commentAuthors.xml")) || + (0 == strcmp (name, "ppt/authors.xml")) || + (has_prefix (name, "ppt/comments/")) ) + return 1; + return 0; +} + + +/* ******************** the two formats ******************** */ + + +/** + * Extract meta data from an Office Open XML package. + * + * @param mc our extraction state + * @return 1 if the file was an OOXML package, 0 if not + */ +static int +extract_ooxml (struct MsoContext *mc) +{ + struct EXTRACTOR_UnzipFile *uf; + char *parts[MAX_PARTS]; + unsigned int num_parts = 0; + unsigned int i; + const char *mime = NULL; + char *buf; + int ret; + + if (NULL == (uf = EXTRACTOR_common_unzip_open (mc->ec))) + return 0; + /* First pass: learn what kind of document this is and which parts + are worth looking at. We collect the names instead of extracting + as we iterate, so that seeking around the archive cannot disturb + the walk over the central directory. */ + ret = EXTRACTOR_common_unzip_go_to_first_file (uf); + while (EXTRACTOR_UNZIP_OK == ret) + { + char name[MAXFILENAME]; + + if (EXTRACTOR_UNZIP_OK == + EXTRACTOR_common_unzip_get_current_file_info (uf, NULL, + name, sizeof (name), + NULL, 0, NULL, 0)) + { + name[sizeof (name) - 1] = '\0'; + if (0 == strcmp (name, "word/document.xml")) + mime = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + else if (0 == strcmp (name, "xl/workbook.xml")) + mime = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + else if (0 == strcmp (name, "ppt/presentation.xml")) + mime = + "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + if ( (is_people_part (name)) && + (MAX_PARTS > num_parts) ) + { + char *dup = strdup (name); + + if (NULL != dup) + parts[num_parts++] = dup; + } + } + ret = EXTRACTOR_common_unzip_go_to_next_file (uf); + } + if (NULL == mime) + { + /* Not an OOXML document -- most likely an ODF file or a plain ZIP, + both of which have plugins of their own. */ + for (i = 0; i < num_parts; i++) + free (parts[i]); + EXTRACTOR_common_unzip_close (uf); + return 0; + } + add_meta (mc, EXTRACTOR_METATYPE_MIMETYPE, mime); + + if (NULL != (buf = read_part (uf, "docProps/core.xml"))) + { + for (i = 0; NULL != core_map[i].text; i++) + report_element (mc, buf, core_map[i].text, core_map[i].type); + free (buf); + } + if (NULL != (buf = read_part (uf, "docProps/app.xml"))) + { + for (i = 0; NULL != app_map[i].text; i++) + report_element (mc, buf, app_map[i].text, app_map[i].type); + free (buf); + } + if (NULL != (buf = read_part (uf, "docProps/custom.xml"))) + { + for (i = 0; NULL != custom_map[i].text; i++) + report_custom_property (mc, buf, custom_map[i].text, custom_map[i].type); + free (buf); + } + for (i = 0; (i < num_parts) && (0 == mc->stop); i++) + { + /* Excel keeps the authors of classic cell comments in a list of + <author> elements rather than in an attribute. */ + if (has_prefix (parts[i], "xl/comments")) + { + if (NULL != (buf = read_part (uf, parts[i]))) + { + report_element (mc, buf, "author", + EXTRACTOR_METATYPE_CONTRIBUTOR_NAME); + free (buf); + } + } + scan_part (mc, uf, parts[i]); + } + for (i = 0; i < num_parts; i++) + free (parts[i]); + EXTRACTOR_common_unzip_close (uf); + return 1; +} + + +/** + * Extract meta data from a bare BIFF stream, as written by Excel 2 to + * Excel 4 (and still produced by some exporters). + * + * @param mc our extraction state + * @return 1 if the file was a BIFF stream, 0 if not + */ +static int +extract_biff (struct MsoContext *mc) +{ + struct EXTRACTOR_ExtractContext *ec = mc->ec; + void *data; + ssize_t avail; + uint64_t fsize; + + fsize = ec->get_size (ec->cls); + if ( (8 > fsize) || + (0 != ec->seek (ec->cls, 0, SEEK_SET)) ) + return 0; + /* The File Protection Block is the very first thing in the stream, + so a small window is enough. */ + avail = ec->read (ec->cls, + &data, + (fsize < 64 * 1024) ? (size_t) fsize : 64 * 1024); + if (8 > avail) + return 0; + if (0 > EXTRACTOR_msoffice_biff_extract ((const unsigned char *) data, + (size_t) avail, + PLUGIN_NAME, + ec->proc, + ec->cls)) + return 0; + add_meta (mc, EXTRACTOR_METATYPE_MIMETYPE, "application/vnd.ms-excel"); + return 1; +} + + +/** + * Main entry method for the MS Office extraction plugin. + * + * @param ec extraction context provided to the plugin + */ +void +EXTRACTOR_msoffice_extract_method (struct EXTRACTOR_ExtractContext *ec); + + +void +EXTRACTOR_msoffice_extract_method (struct EXTRACTOR_ExtractContext *ec) +{ + struct MsoContext mc; + unsigned int i; + void *data; + ssize_t avail; + + memset (&mc, 0, sizeof (mc)); + mc.ec = ec; + if (0 != ec->seek (ec->cls, 0, SEEK_SET)) + return; + if (4 > (avail = ec->read (ec->cls, &data, 4))) + return; + if (0 == memcmp (data, "PK\003\004", 4)) + extract_ooxml (&mc); + else + extract_biff (&mc); + for (i = 0; i < mc.seen_len; i++) + free (mc.seen[i]); +} + + +/* end of msoffice_extractor.c */ diff --git a/src/plugins/ole2_extractor.c b/src/plugins/ole2_extractor.c @@ -1,6 +1,6 @@ /* This file is part of libextractor. - Copyright (C) 2004, 2005, 2006, 2007, 2009, 2012, 2018 Vidyut Samanta and Christian Grothoff + Copyright (C) 2004--2026 Vidyut Samanta and Christian Grothoff libextractor is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published @@ -31,6 +31,7 @@ #include "platform.h" #include "extractor.h" #include "convert.h" +#include "msoffice_biff.h" #include <glib-object.h> #include <string.h> #include <stdio.h> @@ -388,6 +389,193 @@ process_star_office (GsfInput *src, /** + * Report the operating system recorded in the header of an OLE2 + * property set stream. + * + * Every property set stream starts with a PropertySetStream header + * whose PropertySetSystemIdentifier field ([MS-OLEPS] section 2.21) + * names the operating system that wrote it. This is not exposed as a + * property and is therefore invisible to libgsf, but it is a reliable + * indicator of the system a document was authored on. + * + * @param src the property set stream, repositioned to the start on return + * @param reported set to 1 once we reported an operating system; a + * document usually has two property set streams that both + * carry the very same identifier + * @param proc function to call on meta data found + * @param proc_cls closure for @a proc + * @return 0 to continue to extract, 1 if we are done + */ +static int +process_system_identifier (GsfInput *src, + int *reported, + EXTRACTOR_MetaDataProcessor proc, + void *proc_cls) +{ + guint8 hdr[8]; + uint32_t osver; + unsigned int os_type; + unsigned int major; + unsigned int minor; + char buf[128]; + int sret; + + if ((gsf_off_t) sizeof (hdr) > gsf_input_size (src)) + return 0; + if (gsf_input_seek (src, 0, G_SEEK_SET)) + return 0; + /* Copy the header out before seeking back: the buffer handed to us + by gsf_input_read() only stays valid until the input is touched + again. */ + if (NULL == gsf_input_read (src, sizeof (hdr), hdr)) + return 0; + if (gsf_input_seek (src, 0, G_SEEK_SET)) + return 0; + if ( (0xFE != hdr[0]) || + (0xFF != hdr[1]) ) + return 0; /* not a property set stream after all */ + osver = ((uint32_t) hdr[4]) + | (((uint32_t) hdr[5]) << 8) + | (((uint32_t) hdr[6]) << 16) + | (((uint32_t) hdr[7]) << 24); + os_type = (unsigned int) (osver >> 16); + major = (unsigned int) (osver & 0xFF); + minor = (unsigned int) ((osver >> 8) & 0xFF); + switch (os_type) + { + case 0: + sret = snprintf (buf, sizeof (buf), + _ ("Windows (16 bit) %u.%u"), major, minor); + break; + case 1: + sret = snprintf (buf, sizeof (buf), + _ ("Macintosh %u.%u"), major, minor); + break; + case 2: + sret = snprintf (buf, sizeof (buf), + _ ("Windows %u.%u"), major, minor); + break; + default: + return 0; + } + if ( (0 >= sret) || + (sizeof (buf) <= (size_t) sret) ) + return 0; + *reported = 1; + return add_metadata (proc, proc_cls, + buf, + EXTRACTOR_METATYPE_AUTHORING_OS); +} + + +/** + * Function called on the "Workbook" (BIFF8) or "Book" (BIFF5) stream + * of an Excel document. Reports the user names from the File + * Protection Block, which the OLE2 property sets do not cover. + * + * @param src the workbook stream + * @param proc function to call on meta data found + * @param proc_cls closure for @a proc + * @return 0 to continue to extract, 1 if we are done + */ +static int +process_excel (GsfInput *src, + EXTRACTOR_MetaDataProcessor proc, + void *proc_cls) +{ + const guint8 *data; + gsf_off_t size; + size_t want; + + size = gsf_input_size (src); + if (8 > size) + return 0; + /* The File Protection Block is at the very beginning of the globals + substream, so we never need to look at the whole workbook. */ + want = (size > 64 * 1024) ? 64 * 1024 : (size_t) size; + if (gsf_input_seek (src, 0, G_SEEK_SET)) + return 0; + if (NULL == (data = gsf_input_read (src, want, NULL))) + return 0; + return (0 < EXTRACTOR_msoffice_biff_extract (data, + want, + "ole2", + proc, + proc_cls)) ? 1 : 0; +} + + +/** + * Function called on the "Current User" stream of a PowerPoint + * document. Its CurrentUserAtom ([MS-PPT] section 2.3.2) records the + * name of the user who last opened the presentation for editing -- + * which is not the same as, and frequently differs from, the author + * recorded in the summary information. + * + * @param src the "Current User" stream + * @param proc function to call on meta data found + * @param proc_cls closure for @a proc + * @return 0 to continue to extract, 1 if we are done + */ +static int +process_current_user (GsfInput *src, + EXTRACTOR_MetaDataProcessor proc, + void *proc_cls) +{ + /** + * Size of the record header preceding the atom. + */ + static const size_t hdr_size = 8; + /** + * Size of the fixed part of the CurrentUserAtom. + */ + static const size_t atom_size = 20; + const guint8 *d; + gsf_off_t size; + size_t want; + size_t len_user; + char *name; + int ret; + + size = gsf_input_size (src); + if ((gsf_off_t) (hdr_size + atom_size) > size) + return 0; + /* The stream holds nothing but this atom. */ + want = (size > 4096) ? 4096 : (size_t) size; + if (gsf_input_seek (src, 0, G_SEEK_SET)) + return 0; + if (NULL == (d = gsf_input_read (src, want, NULL))) + return 0; + if (0x0FF6 != (d[2] | (d[3] << 8))) + return 0; /* not an RT_CurrentUserAtom */ + len_user = d[hdr_size + 12] | (d[hdr_size + 13] << 8); + if ( (0 == len_user) || + (255 < len_user) || + (hdr_size + atom_size + len_user > want) ) + return 0; + /* The name is stored twice: once in the ANSI code page and, if the + stream is long enough, once more as UTF-16. Prefer the latter. */ + if (hdr_size + atom_size + 3 * len_user + 4 <= want) + name = EXTRACTOR_common_convert_to_utf8 ( + (const char *) &d[hdr_size + atom_size + len_user + 4], + 2 * len_user, + "UTF-16LE"); + else + name = EXTRACTOR_common_convert_to_utf8 ( + (const char *) &d[hdr_size + atom_size], + len_user, + "CP1252"); + if (NULL == name) + return 0; + ret = add_metadata (proc, proc_cls, + name, + EXTRACTOR_METATYPE_LAST_SAVED_BY); + free (name); + return ret; +} + + +/** * We use "__" to translate using iso-639. * * @param a string to translate @@ -929,6 +1117,7 @@ EXTRACTOR_ole2_extract_method (struct EXTRACTOR_ExtractContext *ec) const unsigned char *data512; unsigned int lid; const char *lang; + int os_reported = 0; int ret; void *data; uint64_t fsize; @@ -990,14 +1179,33 @@ EXTRACTOR_ole2_extract_method (struct EXTRACTOR_ExtractContext *ec) if ( ( (0 == strcmp (name, "\005SummaryInformation")) || (0 == strcmp (name, "\005DocumentSummaryInformation")) ) && (NULL != (src = gsf_infile_child_by_index (infile, i))) ) - ret = process (src, - ec->proc, - ec->cls); + { + if (0 == os_reported) + ret = process_system_identifier (src, + &os_reported, + ec->proc, + ec->cls); + if (0 == ret) + ret = process (src, + ec->proc, + ec->cls); + } if ( (0 == strcmp (name, "SfxDocumentInfo")) && (NULL != (src = gsf_infile_child_by_index (infile, i))) ) ret = process_star_office (src, ec->proc, ec->cls); + if ( ( (0 == strcmp (name, "Workbook")) || + (0 == strcmp (name, "Book")) ) && + (NULL != (src = gsf_infile_child_by_index (infile, i))) ) + ret = process_excel (src, + ec->proc, + ec->cls); + if ( (0 == strcmp (name, "Current User")) && + (NULL != (src = gsf_infile_child_by_index (infile, i))) ) + ret = process_current_user (src, + ec->proc, + ec->cls); if (NULL != src) g_object_unref (G_OBJECT (src)); } diff --git a/src/plugins/test_msoffice.c b/src/plugins/test_msoffice.c @@ -0,0 +1,210 @@ +/* + This file is part of libextractor. + Copyright (C) 2026 Vidyut Samanta and Christian Grothoff + + libextractor is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published + by the Free Software Foundation; either version 3, or (at your + option) any later version. + + libextractor is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + General Public License for more details. + + You should have received a copy of the GNU General Public License + along with libextractor; see the file COPYING. If not, write to the + Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. + */ +/** + * @file plugins/test_msoffice.c + * @brief testcase for the msoffice plugin + * @author Christian Grothoff + * + * See testdata/README for where the sample documents come from and + * under what terms they are redistributed here. + */ +#include "platform.h" +#include "test_lib.h" + + +/** + * Shorthand for a UTF-8 text solution entry. + * + * @param t meta data type to expect + * @param s string value to expect + */ +#define TXT(t, s) { t, EXTRACTOR_METAFORMAT_UTF8, "text/plain", s, \ + strlen (s) + 1, 0 } + + +/** + * Main function for the msoffice testcase. + * + * @param argc number of arguments (ignored) + * @param argv arguments (ignored) + * @return 0 on success + */ +int +main (int argc, char *argv[]) +{ + /* A real Word 2007 document: summary, application properties and a + tracked insertion. Note that the tracked change is timestamped + five hours before the modification date in the summary -- Word + writes the local time of the editing session here, which leaks the + author's time zone. */ + struct SolutionData msoffice_word_sol[] = { + TXT (EXTRACTOR_METATYPE_MIMETYPE, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + TXT (EXTRACTOR_METATYPE_TITLE, "Database Deployment Request"), + TXT (EXTRACTOR_METATYPE_CREATOR, "Doug Kanoza"), + TXT (EXTRACTOR_METATYPE_LAST_SAVED_BY, "Administrator"), + TXT (EXTRACTOR_METATYPE_REVISION_NUMBER, "16"), + TXT (EXTRACTOR_METATYPE_LAST_PRINTED, "2113-01-01T04:00:00Z"), + TXT (EXTRACTOR_METATYPE_CREATION_DATE, "2008-09-25T15:27:00Z"), + TXT (EXTRACTOR_METATYPE_MODIFICATION_DATE, "2009-01-21T19:46:00Z"), + TXT (EXTRACTOR_METATYPE_CREATED_BY_SOFTWARE, "Microsoft Office Word"), + TXT (EXTRACTOR_METATYPE_SOFTWARE_VERSION, "12.0000"), + TXT (EXTRACTOR_METATYPE_COMPANY, "NCI"), + TXT (EXTRACTOR_METATYPE_TEMPLATE, "Normal.dotm"), + TXT (EXTRACTOR_METATYPE_TOTAL_EDITING_TIME, "152"), + TXT (EXTRACTOR_METATYPE_PAGE_COUNT, "1"), + TXT (EXTRACTOR_METATYPE_WORD_COUNT, "240"), + TXT (EXTRACTOR_METATYPE_CHARACTER_COUNT, "1371"), + TXT (EXTRACTOR_METATYPE_LINE_COUNT, "11"), + TXT (EXTRACTOR_METATYPE_PARAGRAPH_COUNT, "3"), + TXT (EXTRACTOR_METATYPE_CONTRIBUTOR_NAME, "Administrator"), + TXT (EXTRACTOR_METATYPE_REVISION_HISTORY, + "Author `Administrator' edited the document on `2009-01-21T14:46:00Z'") + , + { 0, 0, NULL, NULL, 0, -1 } + }; + + /* A real Excel 2007 workbook. */ + struct SolutionData msoffice_excel_sol[] = { + TXT (EXTRACTOR_METATYPE_MIMETYPE, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + TXT (EXTRACTOR_METATYPE_CREATOR, "Kelly Kozar"), + TXT (EXTRACTOR_METATYPE_LAST_SAVED_BY, "Kelly Kozar"), + TXT (EXTRACTOR_METATYPE_CREATION_DATE, "2009-01-09T23:02:03Z"), + TXT (EXTRACTOR_METATYPE_MODIFICATION_DATE, "2009-01-10T01:50:37Z"), + TXT (EXTRACTOR_METATYPE_CREATED_BY_SOFTWARE, "Microsoft Excel"), + TXT (EXTRACTOR_METATYPE_SOFTWARE_VERSION, "12.0000"), + TXT (EXTRACTOR_METATYPE_COMPANY, "National Park Service"), + { 0, 0, NULL, NULL, 0, -1 } + }; + + /* A real PowerPoint 2007 presentation. */ + struct SolutionData msoffice_powerpoint_sol[] = { + TXT (EXTRACTOR_METATYPE_MIMETYPE, + "application/vnd.openxmlformats-officedocument.presentationml.presentation"), + TXT (EXTRACTOR_METATYPE_TITLE, + "Assessment of Implantable Monitoring of Heart Failure Patients"), + TXT (EXTRACTOR_METATYPE_CREATOR, "irms"), + TXT (EXTRACTOR_METATYPE_LAST_SAVED_BY, "vhapalsahaya"), + TXT (EXTRACTOR_METATYPE_REVISION_NUMBER, "15"), + TXT (EXTRACTOR_METATYPE_CREATION_DATE, "2007-04-25T02:29:26Z"), + TXT (EXTRACTOR_METATYPE_MODIFICATION_DATE, "2007-11-01T22:43:05Z"), + TXT (EXTRACTOR_METATYPE_CREATED_BY_SOFTWARE, "Microsoft PowerPoint"), + TXT (EXTRACTOR_METATYPE_SOFTWARE_VERSION, "12.0000"), + TXT (EXTRACTOR_METATYPE_COMPANY, "SFVAMC"), + TXT (EXTRACTOR_METATYPE_TOTAL_EDITING_TIME, "70"), + TXT (EXTRACTOR_METATYPE_WORD_COUNT, "827"), + TXT (EXTRACTOR_METATYPE_PARAGRAPH_COUNT, "84"), + { 0, 0, NULL, NULL, 0, -1 } + }; + + /* A bare BIFF stream, as written before Excel used OLE2 containers. + The only meta data such a file has is the user name in the + WRITEACCESS record of its File Protection Block -- which is + exactly the record that https://bugs.gnunet.org/view.php?id=2096 + asks for. */ + struct SolutionData msoffice_biff4_sol[] = { + TXT (EXTRACTOR_METATYPE_LAST_SAVED_BY, "FCC"), + TXT (EXTRACTOR_METATYPE_MIMETYPE, "application/vnd.ms-excel"), + { 0, 0, NULL, NULL, 0, -1 } + }; + + /* A synthetic document exercising the parts that identify people: + the "send for review" custom properties Outlook leaves behind, a + comment, tracked changes by two different authors and the Word + 2013 "people" part that ties an author name to a directory + identity. */ + struct SolutionData msoffice_review_sol[] = { + TXT (EXTRACTOR_METATYPE_MIMETYPE, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + TXT (EXTRACTOR_METATYPE_TITLE, "Quarterly Report & Review"), + TXT (EXTRACTOR_METATYPE_CREATOR, "Jane Doe"), + TXT (EXTRACTOR_METATYPE_LAST_SAVED_BY, "Richard Roe"), + TXT (EXTRACTOR_METATYPE_REVISION_NUMBER, "7"), + TXT (EXTRACTOR_METATYPE_CREATION_DATE, "2026-03-04T09:00:00Z"), + TXT (EXTRACTOR_METATYPE_MODIFICATION_DATE, "2026-03-05T11:45:00Z"), + TXT (EXTRACTOR_METATYPE_CREATED_BY_SOFTWARE, "Microsoft Office Word"), + TXT (EXTRACTOR_METATYPE_SOFTWARE_VERSION, "16.0000"), + TXT (EXTRACTOR_METATYPE_COMPANY, "Example Organisation"), + TXT (EXTRACTOR_METATYPE_TOTAL_EDITING_TIME, "42"), + TXT (EXTRACTOR_METATYPE_AUTHOR_EMAIL, "jane.doe@example.com"), + TXT (EXTRACTOR_METATYPE_AUTHOR_NAME, "Jane Doe"), + TXT (EXTRACTOR_METATYPE_SUBJECT, "Draft for review"), + TXT (EXTRACTOR_METATYPE_CONTRIBUTOR_NAME, "Jane Doe"), + TXT (EXTRACTOR_METATYPE_CONTRIBUTOR_NAME, "Richard Roe"), + TXT (EXTRACTOR_METATYPE_REVISION_HISTORY, + "Author `Jane Doe' edited the document on `2026-03-04T09:15:00Z'"), + TXT (EXTRACTOR_METATYPE_REVISION_HISTORY, + "Author `Richard Roe' edited the document on `2026-03-05T11:42:00Z'"), + TXT (EXTRACTOR_METATYPE_REVISION_HISTORY, + "Author `Richard Roe' edited the document on `2026-03-05T11:40:00Z'"), + TXT (EXTRACTOR_METATYPE_CONTACT_INFORMATION, "richard.roe@example.com"), + TXT (EXTRACTOR_METATYPE_CONTACT_INFORMATION, + "S-1-5-21-1004336348-1177238915-682003330-512"), + { 0, 0, NULL, NULL, 0, -1 } + }; + + /* A synthetic workbook exercising the Excel side: the modern + equivalent of the FILESHARING record, the classic cell comment + author list, the threaded comment "persons" part and the shared + workbook revision log. */ + struct SolutionData msoffice_shared_sol[] = { + TXT (EXTRACTOR_METATYPE_MIMETYPE, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + TXT (EXTRACTOR_METATYPE_CREATOR, "Ada Lovelace"), + TXT (EXTRACTOR_METATYPE_LAST_SAVED_BY, "Grace Hopper"), + TXT (EXTRACTOR_METATYPE_CREATION_DATE, "2026-02-17T08:29:00Z"), + TXT (EXTRACTOR_METATYPE_MODIFICATION_DATE, "2026-02-18T14:06:00Z"), + TXT (EXTRACTOR_METATYPE_CREATED_BY_SOFTWARE, "Microsoft Excel"), + TXT (EXTRACTOR_METATYPE_SOFTWARE_VERSION, "16.0300"), + TXT (EXTRACTOR_METATYPE_COMPANY, "Example Organisation"), + TXT (EXTRACTOR_METATYPE_CONTRIBUTOR_NAME, "Sam Poe"), + TXT (EXTRACTOR_METATYPE_CONTRIBUTOR_NAME, "Ada Lovelace"), + TXT (EXTRACTOR_METATYPE_CONTRIBUTOR_NAME, "Grace Hopper"), + TXT (EXTRACTOR_METATYPE_CONTACT_INFORMATION, "grace.hopper@example.com"), + TXT (EXTRACTOR_METATYPE_REVISION_HISTORY, + "Author `Ada Lovelace' edited the document on `2026-02-17T08:30:11'"), + TXT (EXTRACTOR_METATYPE_REVISION_HISTORY, + "Author `Grace Hopper' edited the document on `2026-02-18T14:05:47'"), + { 0, 0, NULL, NULL, 0, -1 } + }; + + struct ProblemSet ps[] = { + { "testdata/msoffice_word.docx", + msoffice_word_sol }, + { "testdata/msoffice_excel.xlsx", + msoffice_excel_sol }, + { "testdata/msoffice_powerpoint.pptx", + msoffice_powerpoint_sol }, +#if HAVE_ICONV + { "testdata/msoffice_biff4.xls", + msoffice_biff4_sol }, +#endif + { "testdata/msoffice_review.docx", + msoffice_review_sol }, + { "testdata/msoffice_shared.xlsx", + msoffice_shared_sol }, + { NULL, NULL } + }; + return ET_main ("msoffice", ps); +} + + +/* end of test_msoffice.c */ diff --git a/src/plugins/test_ole2.c b/src/plugins/test_ole2.c @@ -111,6 +111,14 @@ main (int argc, char *argv[]) strlen ("2") + 1, 0 }, + { + EXTRACTOR_METATYPE_AUTHORING_OS, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "Windows 1.0", + strlen ("Windows 1.0") + 1, + 0 + }, { 0, 0, NULL, NULL, 0, -1 } }; @@ -219,6 +227,14 @@ main (int argc, char *argv[]) strlen ("The Keywords") + 1, 0 }, + { + EXTRACTOR_METATYPE_AUTHORING_OS, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "Windows (16 bit) 1.0", + strlen ("Windows (16 bit) 1.0") + 1, + 0 + }, { 0, 0, NULL, NULL, 0, -1 } }; #if HAVE_ICONV @@ -444,6 +460,14 @@ main (int argc, char *argv[]) + 1, 0 }, + { + EXTRACTOR_METATYPE_AUTHORING_OS, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "Windows 4.0", + strlen ("Windows 4.0") + 1, + 0 + }, { 0, 0, NULL, NULL, 0, -1 } }; #endif @@ -488,6 +512,139 @@ main (int argc, char *argv[]) strlen ("Microsoft Excel") + 1, 0 }, + { + EXTRACTOR_METATYPE_AUTHORING_OS, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "Macintosh 3.10", + strlen ("Macintosh 3.10") + 1, + 0 + }, + /* The WRITEACCESS record of the File Protection Block names the + user that saved the file independently of the summary + information; here both agree, but they often do not. */ + { + EXTRACTOR_METATYPE_LAST_SAVED_BY, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "JV", + strlen ("JV") + 1, + 0 + }, + { 0, 0, NULL, NULL, 0, -1 } + }; + + /* A PowerPoint presentation. The `Current User' stream records who + last opened the file for editing; note that it disagrees with both + the author and the last-saved-by of the summary information. */ + struct SolutionData ole2_powerpoint_sol[] = { + { + EXTRACTOR_METATYPE_LAST_SAVED_BY, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "FDA.CDER", + strlen ("FDA.CDER") + 1, + 0 + }, + { + EXTRACTOR_METATYPE_LAST_SAVED_BY, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "CDER USER", + strlen ("CDER USER") + 1, + 0 + }, + { + EXTRACTOR_METATYPE_CREATOR, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "CDER.USER", + strlen ("CDER.USER") + 1, + 0 + }, + { + EXTRACTOR_METATYPE_AUTHORING_OS, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "Windows 5.0", + strlen ("Windows 5.0") + 1, + 0 + }, + { + EXTRACTOR_METATYPE_TITLE, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + " Topical Dosage Forms Classification/Nomenclature Introduction", + strlen (" Topical Dosage Forms Classification/Nomenclature Introduction") + + 1, + 0 + }, + { + EXTRACTOR_METATYPE_UNKNOWN_DATE, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "2003-03-07T16:12:40Z", + strlen ("2003-03-07T16:12:40Z") + 1, + 0 + }, + { + EXTRACTOR_METATYPE_CREATION_DATE, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "2003-02-19T00:20:15Z", + strlen ("2003-02-19T00:20:15Z") + 1, + 0 + }, + { + EXTRACTOR_METATYPE_EDITING_CYCLES, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "5", + strlen ("5") + 1, + 0 + }, + { + EXTRACTOR_METATYPE_WORD_COUNT, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "228", + strlen ("228") + 1, + 0 + }, + { + EXTRACTOR_METATYPE_PARAGRAPH_COUNT, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "36", + strlen ("36") + 1, + 0 + }, + { + EXTRACTOR_METATYPE_MIMETYPE, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "application/vnd.ms-files", + strlen ("application/vnd.ms-files") + 1, + 0 + }, + { + EXTRACTOR_METATYPE_CREATED_BY_SOFTWARE, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "Microsoft PowerPoint", + strlen ("Microsoft PowerPoint") + 1, + 0 + }, + { + EXTRACTOR_METATYPE_TEMPLATE, + EXTRACTOR_METAFORMAT_UTF8, + "text/plain", + "C:\\Program Files\\Microsoft Office\\Templates\\Blank Presentation.pot", + strlen ( + "C:\\Program Files\\Microsoft Office\\Templates\\Blank Presentation.pot") + + 1, + 0 + }, { 0, 0, NULL, NULL, 0, -1 } }; @@ -502,6 +659,8 @@ main (int argc, char *argv[]) #endif { "testdata/ole2_excel.xls", ole2_excel_sol }, + { "testdata/ole2_powerpoint.ppt", + ole2_powerpoint_sol }, { NULL, NULL } }; return ET_main ("ole2", ps); diff --git a/src/plugins/testdata/README b/src/plugins/testdata/README @@ -1,8 +1,102 @@ These files are binary test files that were publicly available on the Internet and were reproduced here to test the logic of -the respective plugin. They are not under the "GPL", but +the respective plugin. They are not under the "GPL", but assumed to be reproducable (especially in this context) as they are used for compatibility testing and their content is often so trivial that they may not be copyrightable in the first place. In other cases, they were distributed liberally by the original authors. + + +Provenance of the MS Office samples +=================================== + +The files below were added for the `msoffice' plugin and for the +extensions to the `ole2' plugin that extract the names of the people +who edited a document (https://bugs.gnunet.org/view.php?id=2096). +Because that is precisely the kind of information the plugins are +meant to surface, the provenance and the licensing status of each +file is recorded here explicitly. + +Files taken from Govdocs1 +------------------------- + +Govdocs1 is a research corpus of about one million documents that were +collected by Simson Garfinkel et al. from web servers in the .gov +domain, and which is published for research use at +https://digitalcorpora.org/corpora/file-corpora/files/ with the +statement that the files "are freely available for research and may be +(to the best of our knowledge) freely redistributed". Works of the +U.S. federal government are not subject to copyright protection in the +United States (17 U.S.C. section 105), so these files are believed to +be in the public domain there; as with the pre-existing test files +above, they are reproduced here for compatibility testing. + +Each file was renamed; the original number is the file's identifier +within the corpus and is given below. + + msoffice_word.docx govdocs1 732704.docx + Word 2007 document, "Database Deployment + Request". Carries core and extended + properties and one tracked insertion whose + timestamp is in local time, five hours off + the UTC modification date. + + msoffice_excel.xlsx govdocs1 610528.xlsx + Excel 2007 workbook (National Park + Service). + + msoffice_powerpoint.pptx govdocs1 392790.pptx + PowerPoint 2007 presentation (Department + of Veterans Affairs). + + msoffice_biff4.xls govdocs1 149599.xls + An Excel worksheet written as a bare BIFF + record stream, i.e. without an OLE2 + container, which is how Excel 2 to Excel 4 + stored files. Its only meta data is the + user name "FCC" in the WRITEACCESS record + of the File Protection Block. + + ole2_powerpoint.ppt govdocs1 167115.ppt + PowerPoint 97-2003 presentation (FDA, + Center for Drug Evaluation and Research). + Its `Current User' stream names + "FDA.CDER", which differs from both the + author and the last-saved-by recorded in + the summary information -- a good example + of why the stream is worth reading. + +All five name organisations or role accounts rather than private +individuals. That was a deliberate selection criterion: many +documents in the corpus name identifiable people, and there is no +reason to republish those here when a functionally equivalent sample +does not. + +Synthetic files +--------------- + +The public corpora predate Office 2013, so they contain none of the +parts that tie an author name to a directory identity, and no +documents that had been circulated for review. The following two +files were therefore written by hand for this test suite. They are +valid OPC packages, they are part of libextractor and covered by its +licence, and every name and address in them is fictitious +(example.com is reserved for documentation by RFC 2606). + + msoffice_review.docx Word document with the `_AuthorEmail' and + `_AuthorEmailDisplayName' custom + properties that Outlook leaves behind when + a document is sent out for review, a + comment, tracked changes by two authors, + and a word/people.xml part mapping author + names to an e-mail address and to a + Windows SID. + + msoffice_shared.xlsx Workbook with <fileSharing userName="..."> + (the OOXML descendant of the BIFF + FILESHARING record), a classic cell + comment author list, the threaded comment + `persons' part, and a shared workbook + revision log naming who changed the + workbook and when. diff --git a/src/plugins/testdata/msoffice_biff4.xls b/src/plugins/testdata/msoffice_biff4.xls Binary files differ. diff --git a/src/plugins/testdata/msoffice_excel.xlsx b/src/plugins/testdata/msoffice_excel.xlsx Binary files differ. diff --git a/src/plugins/testdata/msoffice_powerpoint.pptx b/src/plugins/testdata/msoffice_powerpoint.pptx Binary files differ. diff --git a/src/plugins/testdata/msoffice_review.docx b/src/plugins/testdata/msoffice_review.docx Binary files differ. diff --git a/src/plugins/testdata/msoffice_shared.xlsx b/src/plugins/testdata/msoffice_shared.xlsx Binary files differ. diff --git a/src/plugins/testdata/msoffice_word.docx b/src/plugins/testdata/msoffice_word.docx Binary files differ. diff --git a/src/plugins/testdata/ole2_powerpoint.ppt b/src/plugins/testdata/ole2_powerpoint.ppt Binary files differ.