Recursive AI

Recover a corrupted .msg, .doc or .xls file in C#

All onearchive recipes

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.

Terminal
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:

C#
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")}");
Text
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 .msg or .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 .doc files 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

  • DamagedEntries is Extract by default, which writes the shortened streams; Skip writes nothing for them, and Fail throws CompressionFormatException naming the first one. In every mode result.EntriesDamaged and result.DamagedEntryNames (the first 1,000) list them.
  • Without Recovery nothing 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: Size is what could be read, CfbEntry.DeclaredSize what the directory says, and CfbEntry.IsDamaged is true.
  • To read streams without writing them, open a CompoundFileReader with the same options; CompoundFileReader.Damage then 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.