Skip to content

ys-outlook-parser 1.0.0: PST, OST, MSG and OFT for Juntei - #1

Merged
Shirofune-Security merged 59 commits into
mainfrom
ys/1.0.0
Nov 9, 2026
Merged

Shirofune-Security merged 59 commits into
mainfrom
ys/1.0.0

Conversation

@Shirofune-Security

@Shirofune-Security Shirofune-Security commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

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 / .oft files, read-only and leniently, without Outlook or MAPI.

Crates

Crate Library What it is
ys-outlook-parser (outlook/) outlook One item model for every source: pst::PstStore (store header, folders, messages and FAI) and MsgItem (.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 stored
ys-outlook-parser-pst (pst/) outlook_pst Fork of Microsoft's outlook-pst-rs crates/pst (cfb721d, imported with its history)
ys-outlook-parser-msg (msg/) outlook_msg New: .msg / .oft (MS-OXMSG) on cfb 0.16
ys-outlook-parser-rtf (compressed-rtf/) compressed_rtf Fork of outlook-pst-rs crates/compressed-rtf

What the pst fork changes

  • Outlook 2013+ OSTs (wVer 36: 4 KiB pages, 512-byte block alignment, 24-byte trailers, zlib blocks) and wVerClient 12, as in outlook-pst-ost 1.3.0 (MIT).
  • The read fixes still open upstream: PRs #86, #87, #88, #89, #90, #94, #95 (MIT).
  • Our own fixes: a named property's GUID was looked up 3 entries too far; OLE-object attachments were looked up in the message's sub-nodes instead of the attachment's; the store kept its root hierarchy table, which leaked every store; an embedded message's attachments could not be reached.
  • Read-only: the file is never opened for writing.
  • Lenient by default: each defect reading can go past is a Problem and the rest is read; Validation::Strict makes it an error.
  • No input makes it panic, abort or allocate without bound (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):

Facts Count
The same 324,175
Read only by this crate (messages, recipients and attachments libpff fails on, and a store it cannot open) 15,846
FAI (pypff cannot list them) 8,223
Recipient cells libpff gives as empty/zero though the row says they are absent 49,077
Folder PidTagEntryId / PidTagFolderType, computed as MAPI does 2,004
Objects and embedded messages libpff gives as raw node data 480
Unexplained 0

Against 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 .msg files 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 the cfb crate, which the read budget bounds.

Tests

cargo test --workspace: 217 tests, including outlook/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 each tests/fixtures/README.md.

Review fixes

  • Preserve complete LZFu and MELA RTF payloads, including embedded zero bytes and any following content. The comparison with the external Python decoder now hashes every decompressed byte rather than stopping at the first zero; the public-fixture oracle was regenerated and only four RTF hashes changed.
  • Report the actual recovered MELA length when RAWSIZE understates the payload, and flag the mismatch. This preserves the existing read-to-end behavior allowed by MS-OXRTFCP 2.2.3.1.
  • Convert negative fractional OLE Automation dates using the absolute fraction as the time of day, following Microsoft's DATE definition. For example, -0.5 and 0.5 both represent noon on 1899-12-30.
  • Keep recovered Unicode HTML in the unified item body while preserving the damaged raw property and its diagnostics.
  • Decode Unicode4k HIDs with fourteen allocation bits and thirteen block bits, retaining the layout with the heap so it remains correct outside the reader scope. Classic ANSI/Unicode layouts remain unchanged. The synthetic regression exercises every nonzero fourteen-bit allocation index, a second heap block, invalid references, and classic-layout controls. The layout was independently checked against libpff’s table reader.
  • Add six regressions, verified to fail before the corresponding fixes. All 187 workspace tests, strict lint checks, formatting, documentation, the 1.99 MSRV check and the wasm32 build pass locally. Re-ran the field comparisons across 214 public MSG/OFT and 66 public PST/OST files with no unexplained differences.

Additional review fixes

  • Select clean Unicode text instead of an ANSI fallback when both representations are present. HTML selection now follows the MSG reader's binary, Unicode, ANSI priority, preferring a clean representation over a damaged one. Every original property remains available.
  • Preserve malformed Unicode as Value::Damaged with 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.
  • Mark odd-length UTF-16BE text as lossy even when the remaining complete code units decode successfully.
  • Ignore an unset (0) PST message code page so locale, parent-message and default fallback still work.
  • Skip empty attachment filenames when selecting a display name. This fixes four cases in the public corpus, including one where an empty long name hid a valid short filename.
  • Follow nested recovery wrappers and retain recovered categories, task progress and attachment bytes in the corresponding item fields.
  • Add 12 tests (199 workspace tests total), including synthetic end-to-end MSG tests. Workspace tests, strict clippy, formatting, documentation, MSRV 1.99 and the wasm32 build pass locally.
  • Re-ran all 66 public PST/OST and 214 public MSG/OFT comparisons against the recorded independent-oracle output with no unexplained differences. Also checked subjects, plain-text and HTML bodies, attachment names and bytes in the unified model against the MSG reader across all 208 readable public files, including their embedded messages (279 messages total).

Named-property recovery and memory fixes

  • A single invalid PST NAMEID record previously made stream_entry() or hash_bucket() silently return only the preceding records. The collecting methods now return an error. The new stream_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.
  • The unified model copied a string property name for each reference. A synthetic 180 KiB MSG containing 1,500 references to one 64 KiB name aborted under a 64 MiB address-space limit before this fix. The same input now succeeds under that limit, preserving all properties and names (reported peak RSS approximately 11 MiB).
  • NameId::Text now contains Arc<str> so names remain shared from the MSG reader through the unified model and across clones. Rust API note: construct it with NameId::Text(text.into()) when passing a String or string literal.
  • PST string names are borrowed with the new 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.
  • Five added tests cover malformed middle records and partial tails, borrowed-string bounds, shared-name recovery and the overlap budget. All 204 workspace tests pass, as do strict clippy, formatting, documentation, MSRV 1.99 and the wasm32 build.
  • Re-ran comparisons on 66 public PST/OST and 214 public MSG/OFT files: no unexplained differences. The unified MSG field checks also pass. Checked Juntei against the local parser changes without changing its working tree: all six Outlook unit tests and seven Outlook CLI/integration tests pass.

Text-decoding and timestamp review fixes

  • Preserve fractional FILETIME ticks when converting OLE Automation dates. Whole-day and epoch arithmetic now uses integers, and the stored binary fraction is scaled and rounded exactly, avoiding both epoch precision loss and a second floating-point rounding near half ticks. Negative-date conventions and range checks remain covered.
  • Mark invalid or unsupported String8 decoding as 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.
  • Decode PST String8 HTML with PidTagInternetCodepage when valid, while ordinary message text retains its Windows code page. An absent, zero or negative Internet encoding falls back to the message encoding.
  • Ignore negative MSG encoding/locale metadata during encoding selection rather than interpreting it as a large unsigned number. The original property remains available; a synthetic Japanese-locale regression verifies the fallback.
  • Add seven tests: 211 workspace tests pass, along with strict clippy, formatting, documentation, MSRV 1.99 and wasm32 checks. An independent exact-rational reference matches 137,006 OLE-date cases with no differences, including fractional boundaries, negative dates, subnormals and range failures.
  • Re-ran all 66 public PST/OST and 214 public MSG/OFT comparisons against the recorded independent-parser output with no unexplained differences. The unified MSG field comparisons and all 13 Juntei Outlook unit/CLI compatibility tests pass. The PST HTML encoding edge case is covered by a targeted synthetic unit test.

Multi-value integrity and empty-field fallback fixes

  • Compare each MSG multi-valued binary/String8/Unicode element with the size in its length stream. Previously, the table supplied only an element count, so an element truncated to a complete CFB stream could be presented as undamaged. Length mismatches now report the element index and declared/actual sizes while retaining every recovered byte. The check never allocates or truncates according to an untrusted declaration, ignores binary reserved words, and safely handles sparse/out-of-range indexes.
  • An empty long attachment pathname no longer hides the short pathname. An empty recipient display name no longer hides PidTagRecipientDisplayName, and an empty SMTP address no longer hides PidTagEmailAddress in the MSG convenience accessor. Every original property, including empty fields, remains available.
  • Add six tests covering mismatched and matching lengths across all three variable multi-value types, sparse indexes with extreme declarations, unified-model diagnostics/recovery, and attachment/recipient fallbacks. All 217 workspace tests pass, as do strict clippy, formatting, documentation, MSRV 1.99 and wasm32 checks.
  • All 66 public PST/OST and 214 public MSG/OFT comparisons against recorded independent-parser output pass with no unexplained differences. An independent olefile inspection 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/ and compressed-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/ and outlook/ are RPL only.

🤖 Generated with Claude Code

Shirofune-Security and others added 30 commits November 8, 2026 20:09
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>
Shirofune-Security and others added 13 commits November 8, 2026 21:46
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>
Shirofune-Security and others added 3 commits November 8, 2026 23:20
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>
@Shirofune-Security

Copy link
Copy Markdown
Collaborator Author

Checked ad9cc77 and ab82862; both are correct, and 4051337 corrects the text around them.

Checks

  • 4 KiB-page heap ids: a 14-bit allocation index (bits 5–18) and a 13-bit block index (bits 19–31) are what libpff's table reader and its format documentation ("64-bit 4k page table value reference") use. outlook-pst-ost 1.3.0 decodes them as 512-byte-page ids, so the bug came with the fork. Every heap is read under the reader's lock, which sets the format, so large_pages is known when each heap is built.
  • Uncompressed RTF: reading to the end regardless of RAWSIZE is what MS-OXRTFCP 2.2.3.1 recommends ("SHOULD").
  • Negative OLE dates: taking the fraction's absolute value matches Microsoft's DATE type documentation.
  • Tests: cargo test --workspace passes all 187 tests; clippy (-D warnings) and cargo fmt --check are clean.
  • libpff comparison: the crate's dump of the 66 public PST and OST files (6 of them 4 KiB-page OSTs) is byte-identical before and after these commits. None of those OSTs has a heap that reaches past the 512-byte-page layout, so the libpff numbers in the README stand.
  • extract-msg comparison: I reran it on the 214 public .msg and .oft files with the code before and after these commits. Both runs give 0 unexplained differences, and the only changes are 31 RTF hashes, which moved the same way on both sides.

Fixed in 4051337

  • Counts: the README and the PR description gave the extract-msg counts of an earlier run. The current counts, the same before and after these commits, are 2,399 messages, recipients and attachments, 40,905 properties, 109,846 facts the same, and 791 different, all explained.
  • CHANGELOG: the Fixed section lists what was wrong in the forked crates. It keeps the RTF and 4 KiB heap id entries, now described without their tests. The README lists the heap id fix with the fork's other changes to the OST reader.
  • DATE citation: MS-OAUT 2.2.25 doesn't say that a negative date's fraction is taken as its absolute value. The comment in msg/src/time.rs now cites Microsoft's DATE type documentation, which does, and gives an example.

🤖 Generated with Claude Code

Shirofune-Security and others added 2 commits November 9, 2026 08:09
…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>
@Shirofune-Security

Copy link
Copy Markdown
Collaborator Author

Checked 91a93d6. The code is correct, and ce42662 corrects the text around it.

Checks

  • Tests: cargo test --workspace passes all 199 tests; clippy (-D warnings) and cargo fmt --check are clean.
  • Reading Unicode to the end: a Unicode value is now read to the end of its value, which is bounded (a heap allocation or sub-node) on every path, as binary values already were.
  • Code page 0: a PST message whose PidTagMessageCodepage is 0 now falls back the way a .msg file already did; the .msg reader treats 0 as absent.
  • libpff comparison: the crate's dump of the 66 public PST and OST files is byte-identical before and after this commit. None of them has text that is not valid UTF-16 or a Unicode value with an odd length.
  • extract-msg comparison: on the 214 public .msg and .oft files the result is unchanged: 109,846 facts the same, 791 explained, 0 unexplained.

Fixed in ce42662

  • Crate docs: how the outlook crate represents damaged values, and which stored version an item's fields take, is now in the crate's documentation (outlook/src/lib.rs), where cargo doc links it to Value, rather than in the repository README. It also says that an odd last byte is left out of the recovered text, not replaced by U+FFFD.
  • CHANGELOG: the Fixed section lists what was wrong in the forked crates. Of these changes, that is the last byte of an odd-length Unicode value without a terminator, which was dropped and is now kept as UnicodeValue::trailing_byte. The rest changes code that is new in 1.0.0.

Juntei
With this commit, text that is not valid UTF-16 becomes a damaged value. Juntei's Properties table showed a damaged value only as its bytes, so a subject with one bad code unit would have shown as hex alone. Yamato-Security/juntei#139 (b0ec52c) now shows the recovered value, then a note with the bytes, and updates that column's description in all 15 languages. Its Cargo.lock is at ce42662.

🤖 Generated with Claude Code

Shirofune-Security and others added 2 commits November 9, 2026 09:21
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>
@Shirofune-Security

Copy link
Copy Markdown
Collaborator Author

Checked ede8cfa. The code is correct, and c942ef4 puts its notes where they belong.

Checks

  • Tests: cargo test --workspace passes all 204 tests. Clippy (-D warnings), cargo fmt --check and cargo doc (-D warnings) are clean.
  • Shared names: a string name is now shared rather than copied into every property of every item that has it. Before, one long name in a crafted store's string stream, used by many named properties, was copied into each property of each item, which could reach gigabytes. The decode budget (twice the string stream plus 4 KiB) can only be reached by names that overlap, so real stores never hit it.
  • lookup_string_bytes: it checks what StringEntry::read checked (an even size, inside the stream), and lookup_string gives the same results through it.
  • hash_bucket: it now fails on a bad record instead of returning the records before it. Nothing in the crates calls it, and the item reader reads the entry stream with stream_entries.
  • libpff comparison: the crate's dump of the 66 public PST and OST files is byte-identical before and after this commit.
  • extract-msg comparison: the dump of the 214 public .msg and .oft files is byte-identical too.
  • Juntei: it builds against NameId::Text(Arc<str>) without changes, and its 553 tests pass. Add parse-file outlook: PST, OST, MSG and OFT files (part of #138) juntei#139 now uses c942ef4 (16d3370).

Fixed in c942ef4

  • CHANGELOG, Changed: the section lists how the crates differ from upstream. The budget for decoding a store's names now goes with the other limits there, in the "No input makes the crates panic, abort or allocate without bound" entry. Sharing string names is the API of the new outlook crate, which NameId::Text's documentation describes.
  • CHANGELOG, Fixed: the bad name-map record that hid the records after it is listed after the earlier fixes from the fork, in one sentence.
  • pst/README.md: reading the entry stream record by record is now described under "Lenient reading", not at the top of "Limits". lookup_string_bytes is left to its own documentation.
  • outlook crate docs: one sentence now says that string names are shared and how decoding them is bounded. NameId::Text's documentation already says how to make one.

🤖 Generated with Claude Code

Shirofune-Security and others added 2 commits November 9, 2026 10:17
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>
@Shirofune-Security

Copy link
Copy Markdown
Collaborator Author

Checked c0f54da. The code is correct, and 5bf2cc0 fixes two small text problems.

Checks

  • Tests: cargo test --workspace passes all 211 tests. Clippy (-D warnings), cargo fmt --check and cargo doc (-D warnings) are clean.
  • OLE dates: I checked the integer conversion by hand: the bits of a normal or subnormal fraction, rounding half up, the cutoff past 128 bits, the fraction kept positive for dates before 1899-12-30, and overflow returning None. The epoch constant is 109,205 × 864,000,000,000. The test values agree: one f64 step is 2⁻³⁸ days at day 25,569 (3.14 ticks) and 2⁻³⁷ days at day 45,000 (6.29 ticks).
  • Code pages: a negative PidTagMessageCodepage is now ignored in a .msg file, as a PST already did. A PST's ANSI HTML is decoded in PidTagInternetCodepage, as a .msg file's already was.
  • Comparisons: the crate's dumps of the 66 public PST and OST files and the 214 public .msg and .oft files are byte-identical before and after this commit.
  • ANSI text marked damaged: I ran Juntei with --properties over the same public files before and after this commit, once with the default code page 1252 and once with 932. The Properties table has the same 36 damaged values each time, all streams missing from damaged files, so the new marking adds none on these files.

Fixed in 5bf2cc0

  • Reason text: for ANSI text that isn't valid in its code page, the reason printed the encoding with Rust's Debug formatting ("text is not valid in CodePage(932)"), and Juntei shows that reason in its Properties table. It now says "text is not valid in code page 932", and the test checks both this reason and the one for an unsupported code page.
  • Crate docs: damaged ANSI text is now described in the same paragraph as damaged Unicode text: bytes not valid in the code page become U+FFFD, and text in an unsupported code page is read as Windows-1252.

Yamato-Security/juntei#139 now uses 5bf2cc0 (3bcc21c). All 553 tests pass, and its third-party notices are unchanged.

🤖 Generated with Claude Code

@Shirofune-Security
Shirofune-Security merged commit 5d3011b into main Nov 9, 2026
7 checks passed
@Shirofune-Security
Shirofune-Security deleted the ys/1.0.0 branch November 9, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant