Table of Contents

Streaming and validation

Two things separate reading a PST you trust from reading one you do not: how much of it you let into memory at once, and how much of it you insist is well-formed. This guide covers both - the streaming twins of every payload accessor, the four knobs on PstFileOptions, what each PstValidationLevel actually checks, and the complete PstFileError catalogue so you can say why a file was rejected.

The samples run against sample1.pst from the runnable PST sample, copied as archive.pst; the quoted output is what they print.

Pattern 1 - price a payload, then stream it

Every payload has a cheap length and a streaming read beside its buffered convenience. On a PstNode: DataLength sums the data tree's leaf lengths from its index blocks without reading any leaf; ReadAllBytes() flattens the tree into one array; OpenDataStream() returns a seekable read-only Stream that keeps one leaf block resident, so the logical payload can exceed available memory.

using Bodu.IO.Pst;

using PstFile file = PstFile.OpenRead("archive.pst");

PstNodeInfo biggest = file.EnumerateNodes().MaxBy(n => n.DataLength)!;
PstNode node = file.GetNode(biggest.NodeId);

Console.WriteLine($"{node}: {node.DataLength} bytes");   // priced from the data tree's index blocks only

using Stream data = node.OpenDataStream();                // one leaf block resident at a time
byte[] buffer = new byte[16 * 1024];
long total = 0;
int read;
while ((read = data.Read(buffer, 0, buffer.Length)) > 0)
    total += read;

Console.WriteLine($"streamed {total} bytes; ReadAllBytes would allocate {node.ReadAllBytes().Length}");

// 0x00200024 (NormalMessage): 4198 bytes
// streamed 4198 bytes; ReadAllBytes would allocate 4198

The interesting payloads are usually not node data but property values - an attachment's bytes live behind PidTagAttachDataBinary (0x3701) in the attachment object's property context. PstPropertyContext offers the same pair: TryGetValueLength(id, out length) reads only the value's index blocks, and TryOpenValueStream(id, out stream) serves a subnode-resident value block by block - the same stream OpenDataStream returns - while a heap-resident value is served from the decoded heap bytes.

using Bodu.IO.Pst;

using PstFile file = PstFile.OpenRead("archive.pst");

foreach (PstNodeInfo info in file.EnumerateNodes())
{
    if (info.NodeId.Type != PstNodeType.NormalMessage || !info.HasSubnodes)
        continue;

    PstNode message = file.GetNode(info.NodeId);
    foreach (PstNodeInfo child in message.EnumerateSubnodes())
    {
        if (child.NodeId.Type != PstNodeType.Attachment || !message.TryGetSubnode(child.NodeId, out PstNode? attachment))
            continue;

        PstPropertyContext properties = attachment.ReadPropertyContext();

        // 0x3701 PidTagAttachDataBinary: price it without reading it...
        if (!properties.TryGetValueLength(0x3701, out long length))
            continue;

        Console.WriteLine($"{message.Id} -> {child.NodeId}: {length}-byte payload");

        // ...then copy it block by block; MaxNodeDataLength never applies here.
        if (properties.TryOpenValueStream(0x3701, out Stream? payload))
        {
            using (payload)
            using (FileStream target = File.Create($"{child.NodeId.Index}.bin"))
                payload.CopyTo(target);
        }
    }
}

// 0x00200024 -> 0x00008025: 93142-byte payload

Both Try* members return false when the property is absent and throw PstFileFormatException when its storage is malformed. Inline and fixed-width values are exposed through the stream as their raw little-endian bytes for uniformity; the typed accessors on PstPropertyValue remain the natural way to read those.

Table cells have the identical pair on PstTableRow - TryGetCellLength(id, out length) and TryOpenCellStream(id, out stream) - which answer false when the column is missing or the row's existence bitmap marks the cell absent:

using Bodu.IO.Pst;

using PstFile file = PstFile.OpenRead("archive.pst");

foreach (PstNodeInfo info in file.EnumerateNodes())
{
    if (info.NodeId.Type != PstNodeType.ContentsTable)
        continue;

    PstTableContext contents = file.GetNode(info.NodeId).ReadTableContext();
    foreach (PstTableRow row in contents.EnumerateRows())
    {
        // 0x0037 PidTagSubject as the contents table stores it: priced, then opened.
        if (row.TryGetCellLength(0x0037, out long length) && row.TryOpenCellStream(0x0037, out Stream? cell))
        {
            using (cell)
                Console.WriteLine($"{info.NodeId} row {new PstNodeId(row.RowId)}: subject cell {length} bytes, stream length {cell.Length}");
        }
    }
}

// 0x0000808E row 0x00200024: subject cell 52 bytes, stream length 52
Warning

Every stream these members return is bound to the session. After the PstFile is disposed, each read throws ObjectDisposedException - even for blocks already in the cache. Dispose the stream before the session, and never hand one across a using boundary that outlives the file.

Need Buffered Priced Streamed
A node's payload PstNode.ReadAllBytes() PstNode.DataLength PstNode.OpenDataStream()
A property value PstPropertyContext.GetValue / TryGetValue TryGetValueLength TryOpenValueStream
A table cell PstTableRow.TryGetCell TryGetCellLength TryOpenCellStream

The buffered column is governed by MaxNodeDataLength (below); the priced and streamed columns are not.

PstFileOptions

using Bodu.IO.Pst;

var options = new PstFileOptions
{
    ValidationLevel = PstValidationLevel.Strict,   // every CRC and trailer signature enforced
    BlockCacheSize = 512,                          // decoded pages/blocks kept (0 disables the cache)
    MaxNodeDataLength = 64L * 1024 * 1024,         // refuse to materialize a node payload above 64 MiB
    MaxDataTreeLeaves = 16_384,                    // refuse a data tree fanning out to more leaf blocks
};

using PstFile file = PstFile.Open(File.OpenRead("archive.pst"), options);
Console.WriteLine(file.Format);
Property Default Effect When it trips
ValidationLevel Compatible How much cross-checking each read performs - see the next section. Depends on the level.
BlockCacheSize 256 The least-recently-used cache of decoded pages and block payloads, in entries. Each entry is at most one 8 KiB block, so the default bounds the cache near 2 MB per session. Repeated structural reads - B-tree walks, the same node's contexts read twice - are served from it instead of re-reading and re-decoding the source. 0 disables caching; negative values throw ArgumentOutOfRangeException. Not a limit.
MaxNodeDataLength 256 MiB The largest node payload the session materializes: it governs ReadAllBytes, the heap-on-node parse behind ReadPropertyContext / ReadTableContext, and subnode-resident property values read whole. The declared size is what is bounded - a crafted tree can reference the same physical block thousands of times, so the file size proves nothing. OpenDataStream and the TryOpen*Stream members are unaffected by design. Zero or negative values throw. PstFileFormatException with Error == PstFileError.LimitExceeded, at every validation level.
MaxDataTreeLeaves 65,536 The most leaf data blocks one node's data tree may reference (about 512 MiB of 8 KiB blocks). Enforced while the tree's internal blocks are walked, before any leaf is read, for streaming and buffered reads alike - the leaf list itself is the allocation it bounds. Zero or negative values throw. PstFileFormatException with Error == PstFileError.LimitExceeded, at every validation level.

The mail-store reader forwards the first three from OutlookMailStoreReaderOptions and leaves MaxDataTreeLeaves at its default; open the file with PstFile when you need to change it.

What each validation level checks

PstValidationLevel governs only how the reader treats recoverable inconsistencies. The memory-safety invariants - the magic, a declared version, a header of the right size, the sentinel byte, page and block geometry, every bounds check on every offset, and the two resource limits above - hold at every level.

Check Minimal Compatible (default) Strict
Header structure (magic, version, size, sentinel) Yes Yes Yes
Header CRC (dwCRCPartial over 471 bytes) - Yes Yes
Header file length within the stream - - Yes
Page trailer type and B-tree page geometry Yes Yes Yes
Page CRC, trailer signature, and recorded block identifier - - Yes
Block geometry and trailer length Yes Yes Yes
Block CRC, trailer signature, and recorded block identifier - - Yes
Unknown wire type in a property-context record tolerated (kept as a raw dword) tolerated rejected (InvalidPropertyValue)
Unordered property-context keys re-sorted re-sorted rejected
BTree-on-heap key order - - Yes
Row matrix holding more rows than the row index declares tolerated tolerated rejected (InvalidTableContext)

Compatible is the choice for real-world files - writers have shipped stores with stale trailer CRCs that Outlook reads happily. Strict is for validating a corpus or refusing tampered input. Minimal is for salvage: it still walks structure safely but will follow a checksum-failed page as far as the geometry allows.

Pattern 2 - open under Strict and handle a truncated file

A truncated PST is the commonest damage. Under Strict the header's declared file length is compared with the stream at open, so the file is rejected before a single node is read:

using Bodu.IO.Pst;

byte[] bytes = File.ReadAllBytes("archive.pst");
var strict = new PstFileOptions { ValidationLevel = PstValidationLevel.Strict };

try
{
    using var truncated = new MemoryStream(bytes, 0, bytes.Length / 2, writable: false);
    using PstFile file = PstFile.Open(truncated, strict);
    Console.WriteLine($"opened: {file.Format}");
}
catch (PstUnsupportedFormatException ex)
{
    Console.WriteLine($"unsupported: {ex.Message}");
}
catch (PstFileFormatException ex) when (ex.Error == PstFileError.InvalidHeader)
{
    Console.WriteLine($"rejected at open: {ex.Error} - {ex.Message}");
}
catch (PstFileException ex)
{
    Console.WriteLine($"rejected: {ex.Error} - {ex.Message}");
}

// rejected at open: InvalidHeader - The header declares a file length beyond the end of the stream.

Catch PstUnsupportedFormatException first when you want to say "this is an OST" rather than "this is corrupt"; it is a sibling of PstFileFormatException, not a subclass, so the order between those two does not matter, but both derive from PstFileException, which must come last.

The tolerant default opens the same stream - the header is intact and the B-tree pages sit in the surviving half - and fails only when a read reaches past the end:

using Bodu.IO.Pst;

byte[] bytes = File.ReadAllBytes("archive.pst");

using var truncated = new MemoryStream(bytes, 0, bytes.Length / 2, writable: false);
using PstFile file = PstFile.OpenRead(truncated);
Console.WriteLine($"opened: {file.Format}");

try
{
    int nodes = 0;
    long bytesRead = 0;
    foreach (PstNodeInfo info in file.EnumerateNodes())      // the B-tree pages survived the cut
    {
        nodes++;
        bytesRead += file.GetNode(info.NodeId).ReadAllBytes().Length;   // the payloads did not
    }

    Console.WriteLine($"{nodes} nodes, {bytesRead} bytes");
}
catch (PstFileFormatException ex)
{
    Console.WriteLine($"failed while reading: {ex.Error} - {ex.Message}");
}

// opened: Unicode
// failed while reading: InvalidBlock - The block at offset 153152 is malformed or failed its trailer validation.

A read that escapes the file surfaces as InvalidBlock (or InvalidPage for a page), which is why a salvage tool can enumerate what survived and stop cleanly at the cut. Corruption never escapes as any other exception type.

Damage that leaves the geometry intact is where the levels diverge. Flip one byte inside a block's payload and Compatible reads straight through it - the bytes are wrong, but nothing structural disagrees - while Strict catches the CRC:

using Bodu.IO.Pst;

byte[] bytes = File.ReadAllBytes("archive.pst");
bytes[0x4D10] ^= 0xFF;   // one byte inside a data block's payload

foreach (PstValidationLevel level in new[] { PstValidationLevel.Compatible, PstValidationLevel.Strict })
{
    try
    {
        using PstFile file = PstFile.Open(new MemoryStream(bytes), new PstFileOptions { ValidationLevel = level });
        int nodes = file.EnumerateNodes().Count();
        long payload = 0;
        foreach (PstNodeInfo info in file.EnumerateNodes())
            payload += file.GetNode(info.NodeId).ReadAllBytes().Length;
        Console.WriteLine($"{level}: read {nodes} nodes, {payload} payload bytes");
    }
    catch (PstFileFormatException ex)
    {
        Console.WriteLine($"{level}: {ex.Error} - {ex.Message}");
    }
}

// Compatible: read 52 nodes, 16650 payload bytes
// Strict: InvalidBlock - The block at offset 19648 is malformed or failed its trailer validation.

The PstFileError catalogue

Every failure surfaces through PstFileException or a subclass, and every one carries a PstFileError in Error so you can branch without parsing the message.

PstFileError Thrown as Condition
None PstFileException No category was recorded (the base constructors without a category).
InvalidHeader PstFileFormatException The stream does not begin with the !BDN magic; the version word is not a known PST version (and is below the OST range); the header is shorter than the format's header size; the header CRC does not verify (Compatible / Strict); the sentinel byte is not 0x80; or, under Strict, the declared file length lies beyond the end of the stream.
UnsupportedFormat PstUnsupportedFormatException The version word is 36 or above (the 4 KiB-page OST variant), or the content-encoding byte names Windows Information Protection encryption (0x10) rather than none / permute / cyclic.
InvalidPage PstFileFormatException A B-tree page's trailer type does not match what was expected; a page's entry stride, entry count, depth, or level is out of range; a node identifier in a page exceeds 32 bits; or, under Strict, the page's CRC, signature, or recorded block identifier disagrees.
InvalidBlock PstFileFormatException A block's declared length is zero or its on-disk size exceeds the 8 KiB maximum; the trailer's length field disagrees; a read at the block's offset escapes the end of the stream; or, under Strict, the block's CRC, signature, or recorded block identifier disagrees.
InvalidDataTree PstFileFormatException An XBLOCK / XXBLOCK header has the wrong type or level, its entry count overruns the block, a child is not at the level the parent implies, or a referenced block is missing from the block B-tree.
InvalidSubnodeTree PstFileFormatException An SLBLOCK / SIBLOCK is malformed, or a value reference names a subnode the owning node's subnode tree does not contain.
InvalidHeap PstFileFormatException A heap-on-node header or page map is malformed, a heap identifier does not resolve to an item, or a BTree-on-heap is malformed (including, under Strict, keys out of order).
InvalidPropertyContext PstFileFormatException The node's heap does not carry a property context, a record is malformed, or a fixed-width value's payload is shorter than its declared width.
InvalidTableContext PstFileFormatException The node's heap does not carry a table context, the TCINFO header is malformed, the row matrix does not resolve or holds fewer rows than the row index records, the row index names a row the matrix does not hold, or, under Strict, the matrix holds more rows than the index records.
InvalidPropertyValue PstFileFormatException Under Strict, a property-context record carries a wire type the reader does not know (the tolerant levels keep it as a raw dword).
NodeNotFound PstNodeNotFoundException PstFile.GetNode was asked for an identifier the node B-tree does not contain. Use TryGetNode to avoid the exception.
PropertyNotFound PstFileException PstPropertyContext.GetValue was asked for an identifier the context does not contain. Use TryGetValue to avoid the exception.
LimitExceeded PstFileFormatException A data tree's declared total exceeds MaxNodeDataLength on a materializing read, or its leaf count exceeds MaxDataTreeLeaves while the tree is walked.

The three subclasses partition the space: PstFileFormatException for anything structurally wrong, PstUnsupportedFormatException for a file that is well-formed but a variant the library does not read, and PstNodeNotFoundException for a lookup miss on a well-formed file. PropertyNotFound is the one category raised on the base type.

Note

The typed accessors on PstPropertyValue are the one place the library throws something other than the family: a wire-type or width mismatch is a caller error and surfaces as InvalidOperationException. An unreadable or non-seekable stream at open is ArgumentException, and anything after Dispose is ObjectDisposedException.

Where to go next