Extract the file with CompoundFile = new CompoundFileReaderOptions { Recovery = true }: streams that survive are written as usual, and DamagedEntries decides what happens to the ones that were cut short. OnDamagedEntry reports each one, and the result counts them. The program below cuts a real Outlook message short, shows the normal error, then gets the subject, sender and body back.
Outlook .msg files, legacy Office .doc, .xls and .ppt files, and .msi installers are all Microsoft compound files: a small file system of storages and streams inside one file. A copy or download that stops early, or a bad sector, breaks its tables, and a strict reader refuses the whole file. Recovery mode is off by default.
dotnet new console -o RecoverMsg && cd RecoverMsg dotnet add package RecursiveAI.OneArchive --version 1.1.1 curl -LO https://raw.githubusercontent.com/apache/poi/REL_5_3_0/test-data/hsmf/quick.msg
quick.msg is a sample email from the Apache POI test files. Put this in Program.cs and run dotnet run:
using System.Text; using RecursiveAI.OneArchive; using RecursiveAI.OneArchive.Cfb; using RecursiveAI.OneCompress; // Damage the message: keep the first three quarters, as an interrupted copy would. byte[] msg = File.ReadAllBytes("quick.msg"); int cut = msg.Length * 3 / 4; File.WriteAllBytes("damaged.msg", msg[..cut]); Console.WriteLine($"quick.msg: {msg.Length:N0} bytes; damaged.msg: {cut:N0} bytes"); // Without recovery, the damaged file is a typed error. try { Archive.Extract("damaged.msg", "strict"); } catch (CompressionFormatException e) { Console.WriteLine($"without recovery: {e.Message}"); } // With recovery, extract every stream that survives and skip the damaged ones. var options = new ArchiveExtractOptions { CompoundFile = new CompoundFileReaderOptions { Recovery = true }, DamagedEntries = DamagedEntryPolicy.Skip, OnDamagedEntry = e => Console.WriteLine( $" damaged: {e.Name} ({e.Size} of {((CfbEntry)e).DeclaredSize} bytes left)"), }; var result = Archive.Extract("damaged.msg", "recovered", options); Console.WriteLine($"entries written: {result.EntriesExtracted}, damaged and skipped: {result.EntriesDamaged}"); // Outlook keeps each text property in a stream of its own; 001E is 8-bit text. string Read(string id) { string file = $"recovered/__substg1.0_{id}001E"; if (!File.Exists(file)) return "(lost)"; return Encoding.Latin1.GetString(File.ReadAllBytes(file)).Trim('\0', '\r', '\n'); } Console.WriteLine($" Subject: {Read("0037")}"); Console.WriteLine($" From: {Read("0C1A")}"); Console.WriteLine($" Body: {Read("1000")}");
quick.msg: 17,920 bytes; damaged.msg: 13,440 bytes without recovery: cfb: invalid data at byte offset 2144: expected the directory chain to stay inside the file (the file has 25 sectors after the header; found sector 0x00000019) damaged: __substg1.0_00020102 (0 of 16 bytes left) damaged: __substg1.0_00030102 (0 of 72 bytes left) damaged: __properties_version1.0 (320 of 1328 bytes left) damaged: __recip_version1.0_#00000000/__substg1.0_0FF60102 (0 of 4 bytes left) damaged: __recip_version1.0_#00000000/__substg1.0_0FFF0102 (0 of 109 bytes left) damaged: __recip_version1.0_#00000000/__substg1.0_3001001E (0 of 11 bytes left) damaged: __recip_version1.0_#00000000/__substg1.0_3002001E (0 of 2 bytes left) damaged: __recip_version1.0_#00000000/__substg1.0_3003001E (0 of 80 bytes left) damaged: __recip_version1.0_#00000000/__substg1.0_300B0102 (0 of 84 bytes left) damaged: __recip_version1.0_#00000000/__substg1.0_39FE001E (0 of 24 bytes left) damaged: __recip_version1.0_#00000000/__substg1.0_3A20001E (0 of 11 bytes left) damaged: __recip_version1.0_#00000000/__substg1.0_5FF6001E (0 of 11 bytes left) damaged: __recip_version1.0_#00000000/__substg1.0_5FF70102 (0 of 109 bytes left) damaged: __recip_version1.0_#00000000/__substg1.0_6001001E (0 of 15 bytes left) damaged: __recip_version1.0_#00000000/__properties_version1.0 (0 of 392 bytes left) entries written: 41, damaged and skipped: 15 Subject: Test the content transformer From: Kevin Roast Body: The quick brown fox jumps over the lazy dog
The subject, sender and body survive. The 15 streams that lost their sectors, the recipient’s address among them, are reported and not written, so nothing on disk is a cut-short copy. The 41 entries written are 39 streams and the two storages that hold them.
Limits
- It reads, it doesn’t repair. You get the streams back, not a fixed
.msgor.doc: bytes that are gone stay gone. Turning streams into an email or a document is up to a format-specific library. - How much comes back depends on where the damage falls. With no usable header, no usable FAT sector, or no directory even after scanning, recovery still throws
CompressionFormatException. A file that keeps its FAT and directory at the end, as some.docfiles do, loses both when it is cut short. - Damage inside a stream’s bytes isn’t detected. Recovery follows the file’s tables; a stream whose sectors are all present but overwritten reads as intact.
Good to know
DamagedEntriesisExtractby default, which writes the shortened streams;Skipwrites nothing for them, andFailthrowsCompressionFormatExceptionnaming the first one. In every moderesult.EntriesDamagedandresult.DamagedEntryNames(the first 1,000) list them.- Without
Recoverynothing changes, and a file that passes the strict checks reads the same with it on: recovery only starts when the strict reader refuses the file. - A damaged stream keeps the bytes before the first break in its chain:
Sizeis what could be read,CfbEntry.DeclaredSizewhat the directory says, andCfbEntry.IsDamagedis true. - To read streams without writing them, open a
CompoundFileReaderwith the same options;CompoundFileReader.Damagethen lists every problem found, with its kind, offset and reason. - Recovery is bounded:
MaxRecoveryBytes(1 GiB by default) caps the bytes it scans, and limit exceptions are never recovered from.
What is repaired and how is in compound files and damaged entries. Reading an intact file is in Read and write compound files, and a damaged zip in Recover files from a corrupted zip.