Reading compound files
CompoundFile opens an OLE2 / Compound File Binary container and exposes its storage hierarchy and stream payloads. This guide covers the end-to-end recipe: probe the input, open the file, walk the directory, and read a named stream's bytes.
The mental model is a file system in a single file - storages are directories, streams are files. Navigation starts at RootStorage and is scoped to each storage's direct children; there is no path syntax, so you descend one storage at a time. Names are matched with the case-insensitive compound-file relationship, so a request for workbook resolves the Workbook stream. Each entry's name is stored as UTF-16 in a 64-byte field, capping it at 31 characters, and control prefixes are kept verbatim - the \x05 on summary-information streams, the __substg1.0_ on .msg streams - so a lookup must pass the exact name.
Pattern 1 - open a file and read a known stream
using Bodu.IO.Compound;
using CompoundFile file = CompoundFile.Open(File.OpenRead("book.xls"));
CompoundStream workbook = file.RootStorage.OpenStream("Workbook");
byte[] bytes = workbook.ReadAllBytes();
Open reads the stream from its current position to the end. The returned CompoundFile is IDisposable - the using declaration disposes it, which also closes the source stream unless leaveOpen: true was passed. OpenStream resolves a direct child of RootStorage by name and throws CompoundStreamNotFoundException when no such stream exists.
Pattern 2 - probe before opening
using Bodu.IO.Compound;
using FileStream source = File.OpenRead(path);
if (!CompoundFile.IsCompoundFile(source))
{
log.Warn("Not a compound file");
return;
}
using CompoundFile file = CompoundFile.Open(source, leaveOpen: true);
CompoundFile.IsCompoundFile inspects only the eight-byte OLE2 signature (D0 CF 11 E0 A1 B1 1A E1) and restores the stream position before returning, so it is cheap to call ahead of a full open. There is also a ReadOnlySpan<byte> overload for bytes you already hold.
Pattern 3 - walk the hierarchy
using Bodu.IO.Compound;
static void Print(CompoundStorage storage, int depth = 0)
{
foreach (CompoundEntryInfo info in storage.EnumerateEntries())
Console.WriteLine($"{new string(' ', depth * 2)}{info.EntryType}: {info.Name} ({info.Length} bytes)");
foreach (CompoundStorage child in storage.EnumerateStorages())
{
Console.WriteLine($"{new string(' ', depth * 2)}[{child.Name}]");
Print(child, depth + 1);
}
}
using CompoundFile file = CompoundFile.Open(File.OpenRead("message.msg"));
Print(file.RootStorage);
Each CompoundStorage exposes three enumerators over its direct children, all in directory order: EnumerateEntries yields a CompoundEntryInfo metadata snapshot for every child (storage and stream), while EnumerateStorages and EnumerateStreams yield the navigable child storages and stream entries respectively. CompoundEntryInfo carries the Name, EntryType, Length, ClassId, and the creation / modification timestamps.
Pattern 4 - non-throwing lookup
using Bodu.IO.Compound;
if (file.RootStorage.TryOpenStorage("ObjectPool", out CompoundStorage? pool) &&
pool.TryOpenStream("Contents", out CompoundStream? contents))
{
byte[] data = contents.ReadAllBytes();
Process(data);
}
Prefer the TryOpenStorage / TryOpenStream pair when a missing entry is a normal outcome - they return false instead of raising CompoundStreamNotFoundException. The throwing OpenStorage / OpenStream forms are better when the entry is required and its absence is a programming or data error; the exception's StreamName property names the entry that was not found.
Reading the bytes
The CompoundStream returned by OpenStream gives you two ways to read its payload:
| Member | Returns | Use when |
|---|---|---|
ReadAllBytes |
byte[] |
The payload is small and consumed in one pass. |
| the stream itself | a seekable Stream | The payload is large or read incrementally - CompoundStream is a seekable Stream cursor you can hand to BinaryReader, StreamReader, or CopyTo. |
using CompoundStream stream = file.RootStorage.OpenStream("Workbook");
using var reader = new BinaryReader(stream);
ushort recordType = reader.ReadUInt16();
ushort recordSize = reader.ReadUInt16();
See Buffered vs streaming access for how the cursor behaves under buffered and streaming files, and how to bound memory for large payloads.
Pattern 5 - choose a validation level
using Bodu.IO.Compound;
// Strict rejects malformed directory entries and unsorted siblings that the
// default (Compatible) silently tolerates.
var options = new CompoundFileOptions { ValidationLevel = CompoundValidationLevel.Strict };
using CompoundFile file = CompoundFile.Open(File.OpenRead("book.xls"), options);
CompoundFileOptions carries both the read strategy and the CompoundValidationLevel. Every level still enforces the memory-safety invariants (signature, sector sizes, allocation-table bounds, a root storage); the level only governs how the reader treats recoverable inconsistencies. Strict is the choice for validating a trusted corpus, Minimal for best-effort recovery from corruption - it stops a broken chain and yields what it has rather than throwing.
Error handling
A failed open or a malformed stream raises a CompoundFileException subclass. CompoundFileFormatException additionally carries a message-independent CompoundFileError on its Category property, so you can branch on the structural cause without parsing the message:
try
{
using CompoundFile file = CompoundFile.OpenRead(path);
CompoundStream workbook = file.RootStorage.OpenStream("Workbook");
}
catch (CompoundFileFormatException ex) when (ex.Category == CompoundFileError.InvalidSignature)
{
// Not an OLE2 file at all - e.g. a .xlsx (ZIP) was handed in.
}
catch (CompoundFileFormatException ex)
{
log.Warn($"Corrupt compound file: {ex.Category}"); // e.g. FatCycle, StreamChainTooShort
}
catch (CompoundStreamNotFoundException ex)
{
log.Warn($"Missing stream: {ex.StreamName}");
}
| Exception | Category (when applicable) |
Cause |
|---|---|---|
| ArgumentNullException | - | The stream passed to Open is null. |
| ArgumentException | - | buffered: false was requested over a non-seekable stream, or IsCompoundFile was given a non-seekable stream. |
| NotSupportedException | - | An unsupported FileMode / FileAccess combination was requested. |
| CompoundFileFormatException | InvalidSignature, InvalidHeader, TruncatedFile, SectorOutOfRange, FatCycle, InvalidMiniFat, StreamChainTooShort, DirectoryCycle, InvalidRootStorage, … |
The content is not a well-formed compound file, or a stream's sector chain is malformed. |
| CompoundStreamNotFoundException | - | OpenStream / OpenStorage named an entry that does not exist; StreamName names it. |
Tip
Catch the base CompoundFileException when you want to handle every compound-file failure uniformly, then inspect the concrete type or Category only where the distinction matters.
This guide covers the read path. To edit a stream, open the file for write access and use the read-write cursor from OpenStream(name, FileMode, FileAccess) or CreateStream - see Authoring compound files.
Where to go next
- Buffered vs streaming access - the
bufferedflag and theCompoundStreamcursor in depth. - Authoring compound files - create, edit, and commit writable containers.
- Reading property sets - pull authored metadata from the summary-information streams.
- Bodu.IO.Compound API reference.