Repository navigation
ys-outlook-parser 1.0.0: PST, OST, MSG and OFT for Juntei - #1
Conversation
The changes of outlook-pst-ost 1.3.0 (crates.io; Microsoft's outlook-pst 1.2.0 with fixes by Rick Mark-Penwell, MIT), applied unchanged to pst/: - wVer 36 OSTs: 4 KiB B-tree pages with a 24-byte trailer, blocks in 512-byte slots holding up to 65,512 bytes, zlib-compressed data blocks (their inflated size in the block B-tree entry), wVerClient 12 and the OST magic SO. - A heap page whose cFree disagrees with its freed allocations is read (every message of the EDRM Enron PSTs failed on it). - A message node without a sub-node tree opens with no recipient or attachment table. - PtypMultipleGuid is read as concatenated GUIDs, without a count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Packages ys-outlook-parser-pst (library outlook_pst) and ys-outlook-parser-rtf (library compressed_rtf), version 1.0.0, not published, edition 2024, Rust 1.99, resolver 3; authors Microsoft and Zach Mathis. - RPL-1.5 AND MIT: the RPL is LICENSE, upstream's MIT text LICENSE-MIT, and every Rust file starts with the Shirofune Security and Microsoft copyrights, where it is changed from, and the RPL notice. - Upstream's examples are removed (a terminal browser, and an AMap rebuild that writes to the file), and with them every dev-dependency; Empty.pst moves to pst/tests/fixtures. - CI (fmt, clippy, tests and docs on Linux, macOS and Windows, MSRV, wasm32, cargo-deny, the license notice) and deny.toml, as in the other ys-* repositories. - The edition 2024 rewrites (if-let chains, match for if-let-else) and clippy's fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PropertyType::try_from had no arm for 0x000D although PropertyType::Object
exists, so any property context record of type PtypObject failed to parse.
That record is PidTagAttachDataObject (0x3701) on every embedded-message
attachment.
Ported from microsoft/outlook-pst-rs PR #86 ("fix: accept PtypObject
(0x000D) in PropertyType::try_from", baozdemir, fixes #80), MIT; the same
fix is in baozdemir/pstsmith (docs/UPSTREAM.md, "Bugs fixed in upstream
behaviour"), MIT. With its unit test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both loops in HeapTreeInner::entries read records with `while let Ok(..)`,
so the first record that did not parse ended the loop and every record after
it was dropped without an error: a property context or row index came back
quietly missing entries (on embedded-message attachments, everything after
PidTagAttachDataObject, PidTagAttachMethod included). A small read_records
helper now reads a BTH page record by record and returns the first error, and
a page whose size is not a whole number of records is an error.
Ported from microsoft/outlook-pst-rs PR #87 ("fix: read every BTH record and
report the ones that don't parse", baozdemir, fixes #81), MIT; the same fix
is in baozdemir/pstsmith (docs/UPSTREAM.md), MIT. With its three unit tests;
the size check uses is_multiple_of for clippy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three files from the public data set, gzipped (gzip -9 -n, otherwise unchanged) and decompressed in memory by the tests: - dist-list.pst: rjohnsondev/java-libpst f158a64a, Apache-2.0 (the project is Apache-2.0 or LGPL); two embedded-message attachments. - example-2013.ost: the same repository and licence; an Outlook 2013 OST in the 4 KiB layout with zlib-compressed blocks. - outlook.pst: libyal/testdata f018eb7c, CC BY 4.0; libpff's own test file, with recipients and 22 attachments. tests/fixtures/README.md gives each file's source, commit, licence and SHA-256, Empty.pst (Microsoft, MIT) included. tests/common walks every folder, message, recipient and attachment of a store, and tests/fixtures.rs checks that each fixture gives the folder, message and attachment counts libpff gives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For an afEmbeddedMessage attachment, AttachmentInner::read took the node ID
from PidTagAttachDataObject and looked it up in the parent message's sub-node
tree. Per MS-PST 2.4.6.2.2 the NID is a sub-node of the attachment object
(a PtypObject value points at a sub-node of the property context holding it,
2.3.3.5), so on real files the lookup failed with AttachmentSubNodeNotFound.
It now reads the attachment's sub-node tree. The same arm also called
read_embedded, which locks the file, while it still held the file lock; the
message is now read once the lock is released.
Ported from microsoft/outlook-pst-rs PR #88 ("fix: look up an embedded
message in the attachment's own sub-nodes", baozdemir, fixes #82), MIT; the
same fix is in baozdemir/pstsmith (docs/UPSTREAM.md), MIT. The PR's test
reads pstsdk's submessage.pst, which is not taken; tests/fixtures.rs reads
the two embedded messages of the java-libpst fixture dist-list.pst instead
(both failed before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For a string-named entry, NamedPropertyMapProperties::hash_entry returned
the entry with NamedPropertyGuid::None. MS-PST 2.4.7.5 replaces only the ID
with the CRC-32 of the name; wGuid stays, and hash_value() mixes the GUID
index into the bucket number, so lookups of string-named properties (all of
PS_PUBLIC_STRINGS, for example) searched the wrong bucket.
Ported from microsoft/outlook-pst-rs PR #89 ("fix: keep the GUID when
hashing a string-named property", baozdemir, fixes #83), MIT; the same fix
is in baozdemir/pstsmith (docs/UPSTREAM.md), MIT. With its unit test, which
hashes "Keywords" in PS_PUBLIC_STRINGS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Outlook writes an empty string, binary or multi-valued property with an HNID
of 0. In a property context read_property turned it into PropertyValue::Null,
losing the type, and in a table context the cell went to find_entry(HeapId(0))
and failed with InvalidHeapIndex(0). Both now read the empty value of the
type (PropertyValue::empty); Empty.pst's root folder stores its empty display
name this way.
Ported from microsoft/outlook-pst-rs PR #90 ("fix: read an HNID of 0 as the
empty value of the property's type", baozdemir, fixes #84), MIT; the same fix
is in baozdemir/pstsmith (docs/UPSTREAM.md), MIT. With its two unit tests,
which read tests/fixtures/Empty.pst here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NamedPropertyMapProperties::stream_string seeked forward by the entry's size
rounded up to 4 after reading each name, but StringEntry::read has already
consumed the name, so the seek skipped its length a second time and landed in
the middle of the entries after it: stream_string() returned only the first
name of the stream. MS-PST 2.4.7.4 pads each length and string pair to a
4-byte boundary, so only those 0 or 2 bytes are skipped now.
Ported from microsoft/outlook-pst-rs PR #94 ("fix: skip only the padding
after each name in stream_string", baozdemir, fixes #92), MIT. Its test,
which checks that stream_string() holds every name the entry stream points
at, is in named_prop.rs's test module here and reads
tests/fixtures/Empty.pst.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When PidTagAttachDataBinary/PidTagAttachDataObject (0x3701) was missing or of
an unexpected type, the afByValue and afStorage arms of AttachmentInner::read
returned the afEmbeddedMessage errors. They now return
AttachmentFileBinaryDataNotFound / InvalidAttachmentFileBinaryData and
AttachmentStorageObjectDataNotFound / InvalidStorageObjectData, the variants
MessagingError already had for them.
Ported from microsoft/outlook-pst-rs PR #95 ("fix: use the afByValue and
afStorage errors for those attachment methods", baozdemir, fixes #93), MIT.
The lookups are moved into two private AttachmentProperties helpers so that
unit tests can check each method's errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lookup_guid read the GUID stream at u16::from(index) * 16, which is wGuid itself, but the stream starts at wGuid 3 (MS-PST 2.4.7.1): every lookup returned the GUID three entries further on, and one past the end of the stream panicked on the unchecked slice. It now reads the GUID at (wGuid - 3) * 16, returns an error past the end, PS_MAPI and PS_PUBLIC_STRINGS for wGuid 1 and 2, and an error for wGuid 0 (no GUID). The conversion of a GUID index back to wGuid saturates instead of overflowing. The same code is in microsoft/outlook-pst-rs and pstsmith; this fix is ours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
open() opened the evidence file a second time with write access, kept the handle for PstFile::lock(), and a lock rebuilt the allocation map and rewrote the header and density list (start_write/finish_write). All of it is removed: - UnicodePstFile::open, AnsiPstFile::open (and so open_store) open the file with File::open only and read it as read_from does. - PstFile::lock, PstFileLockGuard, the transaction code, the AMap rebuild (rebuild_allocation_map, mark_*_allocations) and the density-list update are gone, as are the writers that place a structure at a file offset: RootBTreePage::write, DataTree::write, SubNodeTree::write, and the header, root, map-page, density-list, B-tree page, block and intermediate-block writers and the header/root mutators behind them. - PstError loses OpenedReadOnly, NoWriteAccess and AllocationMapPageNotFound, which only the write path returned. - The private PstFileLock trait, now only the page caches, is PstFileCaches. - UnicodeHeader and AnsiHeader get nids(), free_map() and free_page_map(), the values only the AMap rebuild used to read. The README says so, and tests/read_only.rs opens a read-only copy of each fixture, reads every folder, message and attachment, and checks that the file's bytes and modification time did not change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both crates now deny, outside tests, clippy's indexing_slicing, arithmetic_side_effects, unwrap_used, expect_used, panic, unreachable, todo and unimplemented, and every site those lints found is fixed: checked or saturating arithmetic, get() instead of indexing, sizes checked before use. Panics the lints do not see are fixed too (RefCell borrows, copy_from_slice). pst: - ndb::block::block_size asserted its size was 1 to 8192; it now returns None outside that range. Data, intermediate and sub-node blocks no longer add cb and the trailer size in u16, and the 4 KiB block reader checks its slot before slicing. - IntermediateTreeBlockReadWrite::read sliced past a cb smaller than the block header, multiplied cEnt by the entry size in u16 and subtracted the header size from cb; the entries are now read with chunks_exact and the trailer is found from cb, where MS-PST puts it (it was computed from the entries, which missed the trailer of 4 messages of an EDRM Enron PST). - The Unicode and ANSI BTree page readers, the 4 KiB page detection and the density list no longer slice or divide unchecked (cbEnt 0 divided by zero). - ltp::heap: find_entry sliced the block with an allocation's offsets unchecked (a new LtpError::HeapAllocOutOfBounds); HeapId::index, the fill-level unpacking (expect) and the HNPAGEMAP arithmetic. - ltp::prop_context: a multi-valued ulCount went straight into Vec::with_capacity (up to 32 GiB, an abort) and item offsets into vec![0; next - start]; the items are now taken from the value's bytes. - ltp::table_context: an rgib[TCI_4b] below 8 made the row reader compute `as usize - 8`, and is now refused; TCI_bm 0 divided by zero; find_row indexed the rows with a row index from the file; column reads, the existence bitmap and the TCINFO offset checks. - ltp::tree, messaging::named_prop (a 4 GiB vec![0; size] for a string entry, lookup_string's slice, the bucket modulo a bucket count of 0), messaging::search, messaging::store, ndb::block_id (next() overflowed). - crc::compute_crc is rewritten with as_chunks and masked table lookups (the same result; a unit test checks it against the bytewise CRC), and the permute and cyclic decoders look their tables up without indexing. - The page caches are borrowed with try_borrow_mut, so a second borrow is an error, not a panic. - AllocationMapPage::find_free_bits, only ever used by the removed AMap rebuild, is removed. compressed-rtf: - decompress_rtf sliced its 16-byte header out of shorter input, added COMPSIZE and 4 in usize (overflow on 32-bit targets), reserved RAWSIZE bytes up front, and sliced RAWSIZE bytes of uncompressed RTF out of the data unchecked (the panic seen on a real MSG): new Error::HeaderTooShort and Error::RawSizeMismatch, and at most 8 times the input reserved. - The dictionary masks every offset to its 4096 bytes and the encoder no longer indexes or adds unchecked. Unit tests cover each site: crafted BTree pages and blocks, heap page maps, TCINFO and rows, multi-valued and named-property sizes, and for compressed-rtf short and inconsistent headers, a 4 GiB RAWSIZE, truncated references, round trips and 12,000 seeded mutations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ders A page, block or record that refers to itself or to one above it made the reader recurse until the stack overflowed (an abort), or re-read the same data without end. Each walk now has limits that guarantee it ends: - NBT/BBT lookups (RootBTreePage::find_entry) keep the file offsets of the pages on their path: a page already on it is BTreePageLoop, a path longer than MAX_BTREE_DEPTH (16; MS-PST has 8 levels) is BTreeTooDeep. A child page whose cLevel is not below its parent's is still followed: an Aspose PST in the public data set has such a stale reference in its BBT, and the lookups through it find the right blocks. - Data trees (XBLOCK, XXBLOCK: DataTree::blocks, reader and nth) and sub-node trees (SIBLOCK, SLBLOCK: SubNodeTree::entries and find_entry) read each block at most once (BlockTreeLoop), at most MAX_BLOCK_TREE_DEPTH (8) levels down (BlockTreeTooDeep), and no more bytes of blocks than the file holds (BlockTreeTooLarge), so many entries for the same bytes cannot multiply what is read. - BTH walks (HeapTree::entries) read each heap allocation at most once (LtpError::HeapTreeLoop): an index record pointing at its own node, with a bIdxLevels of 255, re-read it 255 times over. - Embedded messages are read at most MAX_EMBEDDED_MESSAGE_DEPTH (64) levels down (MessagingError::EmbeddedMessageTooDeep), so an attachment loop ends. - messaging::folder::walk_folders walks every folder reachable from the root folder through the hierarchy tables, each at most once and at most MAX_FOLDER_DEPTH (256) levels down; a folder that does not open comes with its error. - DataTree::level, SubNodeTree::level and RootBTreePage::level are public. Tests: B-tree pages that point at themselves, at each other, upwards and deeper than the limit (unit tests, and crafted copies of Empty.pst in tests/crafted.rs whose lookups overflowed the stack before); XBLOCKs and SIBLOCKs that contain themselves or each other, a block twice in a tree, a tree larger than the file and chains deeper than the limit; BTH index loops; the embedded-message limit; and walk_folders over a mock store with loops and over a chain deeper than the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file is read with a Validation. Lenient, the default, records each defect
reading can go past as a Problem and goes on; Strict makes the first one an
error, as upstream does. A defect reading cannot go past (a truncated file,
a block of the wrong size, a loop) is an error either way.
API (pst::problems, re-exported at the crate root):
- Validation { Lenient (default), Strict }.
- Problem { kind(), offset(), node(), detail() } and Display; ProblemKind
(non_exhaustive): HeaderCrc, HeaderField, PageCrc, PageField, BlockCrc,
BlockField, HeapRecord, Property, SubNodeTree, Table, Attachment,
StoreRecordKey, TooManyProblems.
- PstFile::validation() and PstFile::problems(): the problems found so far,
each once, in the order found, at most MAX_PROBLEMS (10,000).
- UnicodePstFile and AnsiPstFile read_from_with and open_with, and
open_store_with, take a Validation; read_from, open and open_store read
leniently.
What is lenient:
- Header: dwCRCPartial and dwCRCFull, and wMagicClient, wVerClient,
bPlatformCreate, bPlatformAccess, dwAlign, bSentinel and the reserved
bytes. wVerClient 12, which Outlook writes in OSTs of both layouts, is
allowed (two OSTs of the public data set did not open before).
- B-tree page, block, XBLOCK/SLBLOCK and 4 KiB block CRCs, BTPAGE and
SIBLOCK/SLBLOCK dwPadding, and a B-tree page or intermediate block whose
cLevel is not below its parent's. A Unicode page whose CRC matches neither
layout is read in the layout of the file's header.
- Per part, not per object: a BTH record that does not parse (the port of
PR #87 made one such record fail a whole property context; real Aspose
PSTs have all-zero records), a table column descriptor that does not
validate, a property value, a message's sub-node tree, recipient or
attachment table (or more than one: the first is read), an attachment's
method and data, and a table's row index are each left out with a
problem.
- A store without a usable PidTagRecordKey makes entry IDs with a record
key of zeros, so its folders and messages open by node ID.
- A folder table that cannot be read is a problem, whatever the
validation (the table methods return an Option); a folder without one is
not.
How: the file keeps its validation and a problem log, and every read locks
the reader through a guard (try_lock: a re-entrant lock is an error, not a
deadlock) that puts both in a thread-local scope, where the CRC and field
checks deep in the NDB layer find them, with the offset of the page or
block being parsed. Without a scope (structures read on their own), checks
are strict. The density list, which nothing is read through, is read
strictly as before and is not a problem.
TableContextInfo's checks are split into check_offsets and check_column;
the README documents lenient reading and the limits.
Tests: the problem log and scopes; lenient and strict BTH records and TCOLDESC
columns; B-tree, data tree and sub-node tree level checks in both modes;
tests/lenient.rs (header CRCs and fields, a page and a block CRC, a store
without a record key, a property record of an unknown type and a property
value that cannot be read, on crafted copies of Empty.pst, each read
strictly and leniently); and an attachment with an unknown method in
dist-list.pst.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StoreInner kept the root folder's hierarchy table in a OnceCell, and the table holds an Rc of the store: a reference cycle, so once root_hierarchy_table() had been called the store, its file and the file's reader (a whole file in memory, or an open file handle) were never dropped. A long mutation run that opened 3,000 copies of a 16 MiB OST grew to 18.5 GiB this way. The table is now read on each call (it is one table context), and a unit test checks that dropping the store and the table leaves the file with one reference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- tests/crafted.rs adds truncated files (each fixture cut inside its header and at 32 points through it; a file cut inside its header does not open), block sizes and offsets from the block BTree (cb 0, 1, 8177 and 0xFFFF; ib past the end of the file, at its end and over the header), BTree page counts (cEnt past cEntMax, cbEnt 0 and 255, cLevel 200) and root pages outside the file, each read strictly and leniently and walked where it opens. tests/common/craft.rs finds NBT and BBT leaf entries and recomputes header, page and block CRCs, so that each edit reaches what it is meant to. - tests/mutation.rs reads each fixture once through a reader that records which bytes are read, then reads copies with one to four of those bytes changed (a random byte, 0x00, 0xFF, a flipped bit, four random bytes, or another field's 8 bytes copied over, as a loop would be), one in twenty also cut short, from a fixed-seed xorshift: 300 copies each of Empty.pst, dist-list.pst and outlook.pst and 150 of example-2013.ost, one in five read strictly and the rest leniently. Each copy is read in every way the crate offers: every folder (walk_folders), every cell of every table, every message, recipient, attachment and embedded message, the named property map and its lookups, the search update queue and every compressed RTF body (compressed-rtf is a dev-dependency for that). No copy may panic. PST_MUTATION_CASES and PST_MUTATION_SEED make longer runs. With an unchecked heap slice put back, the mutation test finds 7 panics; 160,000 mutations in release builds (half with overflow checks and debug assertions) found none once the root hierarchy table cycle was fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the crate ys-outlook-parser-msg (library outlook_msg), written from MS-OXMSG, MS-OXCDATA and MS-OXPROPS on top of the cfb crate: - the message, its recipients and attachments, and embedded messages, recursively up to a depth limit; OLE-object attachments as storage trees - every property: property stream entries (32-, 24- and 8-byte headers), __substg1.0_ streams, multi-valued properties (fixed- and variable-size), streams without entries; each value is decoded, unsupported (raw bytes) or damaged (with what could be recovered) - named properties from __nameid_version1.0 (PS_MAPI, PS_PUBLIC_STRINGS and the GUID stream) - PtypString8 in the message's code page (PidTagMessageCodepage, else the locale's ANSI code page, else the parent's, else PidTagInternetCodepage), UTF-16 kept lossless (raw bytes and a lossy flag) - the HTML body and the RTF body (compressed_rtf), with CRC and size problems as flags - the root CLSID, so that templates (.oft) are told from messages Reading fails soft: damaged streams, properties, recipients and attachments become problems of their message and their siblings are still read; the total amount read is capped by the input size. A file whose FAT describes more sectors than it has is retried with the missing sectors read as zeros. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add 17 .msg/.oft files from the test data of Apache POI (Apache-2.0), HiraokaHyperTools/msgreader (Apache-2.0) and the Aspose.Email examples (MIT), unchanged, with their sources, commits, hashes and licence texts (tests/fixtures/README.md, LICENSES/). They cover Unicode and ANSI mail (Windows-1251, code page 932), a contact, an appointment, a meeting request, a task, a sticky note, a distribution list, attachments by value, an OLE-object attachment, embedded messages three levels deep, RTF bodies (one with a bad RAWSIZE), a template (.oft) and two malformed compound files. tests/fixtures.rs checks what is read from each: classes, subjects and bodies in the right code page, named and multi-valued properties, recipients, attachment data, the embedded-message tree and its depth limit, the template CLSID, RTF flags, and that every readable fixture has no problems but the known one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/oracle/extract_msg_dump.py dumps every property of the message, its recipients, attachments and embedded messages (tag, flags, normalized value, raw stream hash), the named-property mapping, attachment kinds and the decompressed RTF, using extract-msg 0.58.2 (GPL-3.0) only as an external oracle through its API. The crate writes the same format (tests/common/dump.rs, examples/msg_dump.rs) and compare.py compares the two, explaining the differences where extract-msg is known to be wrong or to do less (known_differences.tsv and built-in rules). On all 214 distinct compound files of the comparison corpus (41002 properties) every difference is explained: extract-msg's FILETIME rounding and "null date" handling, its ISO-8859-15 default for ANSI strings without PidTagMessageCodepage, one property id kept per named identity, an attachment method it invents, an OLE attachment it does not handle, and five files that cfb rejects (two fuzzed, two intentionally cyclic, one with a 70-character directory name). tests/oracle.rs runs the same comparison on the fixtures against the committed oracle output (fixtures.oracle.tsv.gz) and fails on any difference not listed in fixtures.differences.tsv. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/robustness.rs checks that nothing panics on: - seeded byte-level mutations of seven fixtures (bit flips, overwritten runs, extreme 32-bit fields, truncation, copied spans); - seeded mutations inside the streams of valid compound files, rewritten with cfb (property streams, named-property streams, RTF, multi-valued length and element streams truncated, overwritten, extended, given lone surrogates; streams and storages removed); - embedded messages nested past the depth limit; - stream lengths far past the end of the file; - streams that all share one sector chain, which now hit the cap on the total data read (lowered to twice the file plus 64 KiB, from twice the file plus 1 MiB, so that small files cannot expand to a megabyte); - every file under OUTLOOK_MSG_CORPUS, when set. OUTLOOK_MSG_MUTATION_SCALE multiplies the rounds for longer runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Warn on undocumented public items (clippy runs with -D warnings), and say that the file-level problems include the compound file's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cfb accepts these names but resolves paths, so reading an OLE-object attachment's storage tree through such an entry led back to the storage itself or to its parent: unbounded recursion (a stack overflow) for "", and exponential work for "." and "..". Skip them and report them as unknown entries. The test builds such a file by renaming entries of a version 3 compound file (the test helper now writes version 3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A top-level property stream that is exactly 24 bytes (the embedded header) was dropped as too short; it is now read with that header, like longer streams whose length fits 24 but not 32. A tag listed several times in a property stream is reported once with its count instead of once per extra entry. The robustness test helper now writes paths with `/` on every platform. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cfb checks the order of entry names only against their neighbours in the tree, so a storage can hold two entries whose names are equal ignoring case. As paths both lead to the same entry; following both at each level made the work exponential (a 13 KB file nesting such a pair 20 levels deep listed 4 million storages; 16 levels of attachments gave 131 070 embedded messages). The second entry is now reported and left out, and the number of entries listed is capped at what the file can hold (one per 128 bytes). Stream buffers were reserved for the declared length (up to the file size) even when nothing could be read, and kept: 2 000 empty streams declaring 2 GiB in a 400 KB file held 780 MB. They now start small, grow with the data and are shrunk to it. After the zero-padding fallback, the read limits use the real file length, not the padded one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each string-named entry decoded its name again, and every property with an id of 0x8000 or more cloned it: 16 384 entries pointing at one 128 KB name made a 270 KB file hold 1 GiB of names. Names are now decoded once per string-stream offset and shared (PropertyName::Name holds an Arc<str>), and the total decoded is capped at twice the string stream's length (names do not overlap in a well-formed file); a name past the cap is reported and left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compressed_rtf fails when the data ends inside a dictionary reference, or where one should start, so a cut-short RTF body gave no RTF at all (980 of 1 063 cut points of a sample stream). The token stream is now walked first: a partial reference is dropped and the remaining bits of its control byte are made literals, so the decompressor stops cleanly at the end of the data. A stream without the end marker gets the new flag RtfFlag::Truncated (no file of the comparison corpus lacks it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ys-outlook-parser (library outlook) reads an item's well-known fields from its MAPI properties, whichever reader gave them: the message class and its kind (mail, meeting message, appointment, contact, distribution list, task, task request, note, journal, post, report, configuration), subject, conversation, sender and represented sender, Internet message id and references, transport headers, the four times, flags, the bodies (text, HTML bytes, compressed and decompressed RTF), categories, the reminder, the recipients with their roles, the attachments with their data or embedded message, and the own fields of contacts, calendar items and tasks from their named properties. Every property is kept as stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AttachmentData::Message holds an embedded message as Rc<dyn Message>, but an attachment is read from an Rc<UnicodeMessage> or Rc<AnsiMessage>, so the attachments of an embedded message (a forwarded mail's files) could not be read. UnicodeAttachment and AnsiAttachment now give the message as its own type too, with embedded_message(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README says what each crate reads, why the fork is of outlook-pst and what it changes, how PST and OST files compare with libpff and .msg files with extract-msg on public files, what is not read yet, and how the code is licensed. The CHANGELOG lists the fork's changes for 1.0.0, and the outlook crate's documentation has the README's example as a doctest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lenient read leaves out a TCOLDESC that does not validate, but TableRowData::read sized each row's existence bitmap (rgCEB) from the columns that were kept rather than the file's cCols. With 9 columns and one left out, rgCEB shrank from 2 bytes to 1, so every row after the first in a row-matrix block was read one byte further off (row IDs 0x100, 0x10180, 0x10280FF...): wrong data, not an error. rgCEB is now sized from rgib (rgib[TCI_bm] - rgib[TCI_1b]), which TCINFO's checks hold to the file's column count. Found by an independent review. The test reads three rows after a left-out ninth column and checks each row's ID and cells. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A folder's hierarchy, contents and associated contents tables were read on first use and, because the table methods return an Option, a table that could not be read became None and a problem whatever the validation, which the README says Strict does not do. Read strictly, a folder now reads its three tables when it is opened, so open_folder fails with the table's error; read leniently they are still read on first use, None and a problem when they cannot be read. The Folder trait and the README say so. Found by an independent review. tests/lenient.rs points a folder's contents-table node at the store's property context and checks both modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The BTH walk reads each heap allocation once, but a record that pointed at a node already read was an error even when reading leniently, so one repeated HID lost every property of the object (or every row index entry of a table). Read leniently, such a record is now a HeapRecord problem and is skipped, as is a record whose node cannot be found; the other records are read. Read strictly, both are still errors. Found by an independent review. The test walks an index with records for two leaves, the first leaf again and a missing node, in both modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HeapNodeInner::find_entry parsed the block's page map (HNPAGEMAP) on every lookup, and reading an object looks its heap up once for each property, so an object with many properties in a heap with a large page map took quadratic time: 65,535 properties against a page map of 32,000 allocations took 14.5 s in a release build. Each block's page map is now parsed on its first lookup and kept (a page map that does not parse keeps its error), and the same lookups take 1.3 ms (35 ms in a debug build). Found by an independent review. The test makes 60,000 lookups in a page map of 30,000 allocations and checks that the page map, or its error, is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The walk over a data or sub-node tree counted each block's stored size against the file's, but in the 4 KiB layout a 512-byte slot can inflate to 64 KiB and many block IDs could name the same slot: a 128 KiB file read as 16.4 MB of blocks (124 times its size), which a table turned into 2.05 million rows of 104 bytes and four allocations each, a 262 MB peak. - TreeWalk now refuses a second block at the same file offset (each block has its own slot; NdbError::BlockTreeSharedOffset) and limits the inflated bytes of a walk to MAX_BLOCK_TREE_INFLATION (8) times the file. The most compressible OST of the public data set inflates to 1.1 times its size over all of its blocks. - TableRowData keeps the row's bytes after PidTagLtpRowVer in one boxed slice (40 bytes and one allocation per row instead of 104 bytes and four); its cells are still read when asked for, and its API is unchanged. The reviewer's 128 KiB file now fails at its second block; with a slot for each block, the largest walk the limit allows inflates to 1 MiB and makes 131,024 rows of 40 bytes, a 12 MB peak. The README's limits say so. Found by an independent review. Tests: blocks that share an offset, a tree larger than the file, 4 KiB blocks of 64 KiB of zeros on both sides of the inflation limit, and the size of a row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Many property records naming one heap allocation (or one sub-node) were each copied: 8,000 records naming one 60,000-byte allocation in a 124,056-byte heap read as 457 MiB (474 MB peak). Each heap allocation or sub-node is now the value of the first property naming it; a later property naming it is a Property problem when reading leniently, else an error. And an object's property values hold no more than MAX_BLOCK_TREE_INFLATION times the file's size, which also bounds distinct sub-nodes whose data trees name the same blocks: a property past that is a problem, else an error. The same case now holds one 60,000-byte value (9.6 MB peak) and records 7,999 problems. None of the 66 public PST/OST files has a property sharing a value or reaches the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o table BOOL as true An HNID of 0 is how Outlook stores an empty variable-size or multi-valued value, but a fixed-size type (PtypTime, PtypInteger64, PtypFloating64, PtypCurrency, PtypFloatingTime, PtypGuid, PtypObject) has no empty value, and one read as PtypNull without a word. It is now an error (EmptyFixedSizePropertyValue): in a property context a Property problem that leaves the property out when reading leniently, and from a table's read_column an error for that cell. A table BOOL cell other than 0 or 1 made columns() fail for the whole row, while a property context reads any nonzero BOOL as true. A table now reads it the same way, and InvalidTableColumnBooleanValue is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is embedded in An attachment whose embedded message's sub-node entry names the data and sub-node blocks of the message it is attached to (or of a message that one is embedded in) made a loop that was cut only at MAX_EMBEDDED_MESSAGE_DEPTH, re-reading the same message at every level. Each message now keeps the data and sub-node blocks of the messages it is embedded in and its own (message_blocks(), replacing embedded_depth()), and read_embedded() refuses a message with one of its parents' pairs: an Attachment problem that leaves the attachment without data when reading leniently, else an error. The depth limit still ends a chain of distinct messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With 2647e43 the pst crate refuses an embedded message that has the blocks of a message it is embedded in, so the damaged fixture's two embedded messages are not read, and each is a problem of the store. The per-item cap stays for embedded messages that are shared but not a loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
outlook_msg reports a damaged compressed RTF body as a problem of the message, and Item::build, which decompresses the body again, reports it too. The MSG adapter now leaves out the reader's RTF problems. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README's extract-msg comparison gave the counts of an earlier run: over the same 214 files the code before and after the RTF changes reads 2,399 objects and 40,905 properties, with 109,846 facts the same. The CHANGELOG's Fixed section lists what was wrong in the forked crates, so it keeps the RTF and 4 KiB heap id fixes, described without their tests; the README lists the heap id fix with the fork's other changes. MS-OAUT does not say that the fraction of a negative DATE is taken as its absolute value; Microsoft's DATE type documentation does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Checked ad9cc77 and ab82862; both are correct, and 4051337 corrects the text around them. Checks
Fixed in 4051337
🤖 Generated with Claude Code |
…m the fork How the outlook crate represents damaged values and which stored version an item's fields take is its API's behaviour, so it is described in the crate's documentation, where cargo doc shows it with links, rather than in the repository's README. The CHANGELOG's Fixed section lists what was wrong in the forked crates: of the last changes, that is the dropped odd last byte of a Unicode value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Checked 91a93d6. The code is correct, and ce42662 corrects the text around it. Checks
Fixed in ce42662
Juntei 🤖 Generated with Claude Code |
The CHANGELOG's Changed section lists how the crates differ from upstream, so the budget for decoding a store's named-property names joins the other limits there, and the fix for a bad name-map record hiding the records after it is listed after the earlier fixes from the fork. In pst/README.md, reading the entry stream record by record goes with lenient reading rather than the limits; the crate documentation of the outlook crate says in one sentence that string names are shared and how their decoding is bounded, since NameId::Text's own documentation says how to make one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Checked ede8cfa. The code is correct, and c942ef4 puts its notes where they belong. Checks
Fixed in c942ef4
🤖 Generated with Claude Code |
The reason of an ANSI value that is not valid in its code page printed the
encoding with Rust's Debug formatting ("CodePage(932)"), and Juntei shows that
reason to its users; it now says "code page 932", and the test checks both
reasons. The crate documentation describes damaged ANSI text in the same
paragraph as damaged Unicode text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Checked c0f54da. The code is correct, and 5bf2cc0 fixes two small text problems. Checks
Fixed in 5bf2cc0
Yamato-Security/juntei#139 now uses 5bf2cc0 (3bcc21c). All 553 tests pass, and its third-party notices are unchanged. 🤖 Generated with Claude Code |
The Outlook parser for Juntei (Yamato-Security/juntei#138): PST and OST stores (ANSI and Unicode PSTs, and OSTs including the 4 KiB pages and compressed blocks of Outlook 2013 and later) and
.msg/.oftfiles, read-only and leniently, without Outlook or MAPI.Crates
ys-outlook-parser(outlook/)outlookpst::PstStore(store header, folders, messages and FAI) andMsgItem(.msg,.oft), each item with its well-known fields (class, sender, recipients, times, bodies, attachments, embedded messages, contact / calendar / task fields) and every property as storedys-outlook-parser-pst(pst/)outlook_pstcrates/pst(cfb721d, imported with its history)ys-outlook-parser-msg(msg/)outlook_msg.msg/.oft(MS-OXMSG) oncfb0.16ys-outlook-parser-rtf(compressed-rtf/)compressed_rtfcrates/compressed-rtfWhat the pst fork changes
wVer36: 4 KiB pages, 512-byte block alignment, 24-byte trailers, zlib blocks) andwVerClient12, as inoutlook-pst-ost1.3.0 (MIT).Problemand the rest is read;Validation::Strictmakes it an error.indexing_slicing,arithmetic_side_effects,unwrap_used… denied), and every walk ends: B-tree, block, heap, folder and embedded-message loop and depth limits, inflated bytes capped per walk, each heap allocation the value of one property, embedded messages that hold themselves refused.Validation
PST/OST against libpff (pypff 20260926, used only as an external oracle), every property of every store, folder, message, recipient and attachment and every attachment's data, on 66 public PST and OST files (Apache Tika, artemis, Aspose.Email examples, DFIR Artifact Museum, Enron (ZL Technologies / EDRM), GroupDocs examples, java-libpst, libyal testdata, outlook-pst-rs, pst-extractor):
PidTagEntryId/PidTagFolderType, computed as MAPI doesAgainst upstream outlook-pst on the same files: 3,557 messages read instead of 3,326, no failing message or attachment (upstream 134 and 221), 258 embedded messages (upstream 0).
MSG/OFT against extract-msg (0.58.2, external oracle): 214 public files, 40,905 properties, 109,846 facts the same, 791 different and all explained (601 are extract-msg FILETIME errors, 35 ANSI files it decodes in the wrong code page, the rest what it leaves out or adds).
Damaged input: crafted files and seeded mutations for each crate (more than 260,000 mutated
.msgfiles and 160,000 mutated PSTs) without a panic, abort or unbounded allocation. Independent reviews of the two readers found 6 problems in msg and 8 (plus 2 minor) in pst; all are fixed with regression tests, except a quadratic walk of sector chains inside thecfbcrate, which the read budget bounds.Tests
cargo test --workspace: 217 tests, includingoutlook/tests/pst_oracle.rs(the libpff comparison repeated on the fixtures against libpff's committed output),msg/tests/oracle.rs(same for extract-msg) and the crafted/mutation tests. Fixtures are openly licensed (libyal testdata CC BY 4.0; java-libpst, Apache POI, msgreader Apache-2.0; Aspose.Email examples MIT; outlook-pst-rs MIT), documented with sources and hashes in eachtests/fixtures/README.md.Review fixes
-0.5and0.5both represent noon on 1899-12-30.Additional review fixes
Value::Damagedwith recovered text and original bytes, including multi-valued properties. The PST reader also retains an unmatched final byte of an unterminated Unicode string instead of dropping it; the byte is included in its read budget.UnicodeValue::trailing_byte()exposes it to low-level consumers.0) PST message code page so locale, parent-message and default fallback still work.Named-property recovery and memory fixes
stream_entry()orhash_bucket()silently return only the preceding records. The collecting methods now return an error. The newstream_entries()iterator returns each record's result separately, including a partial final record; the forensic store reader reports bad records and continues to later valid ones.NameId::Textnow containsArc<str>so names remain shared from the MSG reader through the unified model and across clones. Rust API note: construct it withNameId::Text(text.into())when passing aStringor string literal.lookup_string_bytes(), decoded once per offset and shared. Different overlapping offsets are bounded to twice the source string stream's byte length plus 4096 bytes; an excess is reported without discarding previously recovered names. Repeated references to the same offset do not consume that budget again.Text-decoding and timestamp review fixes
Value::Damaged, retaining recovered text, original bytes and the reason. This also covers multi-valued strings and prevents a lossy ANSI value from hiding a recovered Unicode representation.PidTagInternetCodepagewhen valid, while ordinary message text retains its Windows code page. An absent, zero or negative Internet encoding falls back to the message encoding.Multi-value integrity and empty-field fallback fixes
PidTagRecipientDisplayName, and an empty SMTP address no longer hidesPidTagEmailAddressin the MSG convenience accessor. Every original property, including empty fields, remains available.olefileinspection also finds zero length mismatches across 233 multi-value elements in the public corpus. Unified MSG field checks and all 13 Juntei Outlook unit/CLI compatibility tests pass.Not in this PR
OAB, the autocomplete stream (NK2), SRS, settings caches (RoamCache, Autodiscover), Outlook's SQLite databases, and deleted messages no longer in a folder's table: follow-up PRs for #138.
Licence
RPL-1.5.
pst/andcompressed-rtf/are changed from outlook-pst-rs (Microsoft) and outlook-pst-ost 1.3.0, whose code stays under MIT (LICENSE-MIT):RPL-1.5 AND MIT, noted in each changed file's header.msg/andoutlook/are RPL only.🤖 Generated with Claude Code