Recursive AI

Recover files from a corrupted zip in C#

All onearchive recipes

Open the zip with new ZipReaderOptions { Recovery = true } and read the entries as usual: what survives comes back, and ZipReader.Damage lists every entry and byte range that was lost. The program below cuts a zip short, shows the normal error, then recovers what is left.

A zip’s directory sits at the end of the file, so a download cut short loses it, and with it the whole archive. Recovery mode reads the local headers instead, skips what it can’t parse, and keeps going. It is new in 1.0 and off by default.

Terminal
dotnet new console -o ZipRecovery && cd ZipRecovery
dotnet add package RecursiveAI.OneArchive --version 1.0.0

Put this in Program.cs and run dotnet run:

C#
using RecursiveAI.OneArchive;
using RecursiveAI.OneArchive.Zip;
using RecursiveAI.OneCompress;
 
// A zip of four day logs, then damaged: a run of bytes overwritten inside one entry, and the download cut short.
Directory.CreateDirectory("logs");
for (int d = 1; d <= 4; d++)
    File.WriteAllText($"logs/day{d}.log", string.Concat(Enumerable.Range(0, 400).Select(i => $"day {d} event {i}: sensor {i % 7} ok\n")));
Archive.Create("logs.zip").Add("logs/").Save();
byte[] zip = File.ReadAllBytes("logs.zip");
byte[] damaged = zip[..(zip.Length * 4 / 5)];
for (int i = 0; i < 16; i++) damaged[damaged.Length / 3 + i] ^= 0xFF;
File.WriteAllBytes("damaged.zip", damaged);
Console.WriteLine($"logs.zip: {zip.Length:N0} bytes; damaged.zip: {damaged.Length:N0} bytes");
 
// Without recovery, a damaged archive is a typed error.
try { Archive.Extract("damaged.zip", "plain"); }
catch (CompressionFormatException e) { Console.WriteLine($"without recovery: {e.Message}"); }
 
// With recovery, read every entry that survives and list what was lost.
using var reader = new ZipReader(File.OpenRead("damaged.zip"), new ZipReaderOptions { Recovery = true });
while (reader.GetNextEntry() is { } entry)
{
    if (entry.Kind != ArchiveEntryKind.File) continue;
    try
    {
        using var s = reader.OpenEntryStream();
        Console.WriteLine($"  ok       {entry.Name} ({new StreamReader(s).ReadToEnd().Length:N0} chars)");
    }
    catch (CompressionFormatException) { Console.WriteLine($"  damaged  {entry.Name}"); }
}
foreach (ZipDamage d in reader.Damage)
    Console.WriteLine($"{d.Kind,-17} at byte {d.Offset,5:N0}  {d.EntryName ?? "(no entry)"}");
Text
logs.zip: 6,203 bytes; damaged.zip: 4,962 bytes
without recovery: zip: invalid data at byte offset 0: expected an end of central directory record (PK\5\6) in the last 65557 bytes; this is not a zip archive, or it is truncated
  ok       logs/day1.log (11,490 chars)
  damaged  logs/day2.log
  ok       logs/day3.log (11,490 chars)
  damaged  logs/day4.log
DirectoryUnusable at byte     0  (no entry)
DamagedEntry      at byte 1,477  logs/day2.log
Truncated         at byte 4,271  logs/day4.log

Limits

  • It reads, it doesn’t rewrite. Bytes that are gone stay gone; to get a clean zip, extract what survived and zip it again.

Good to know

  • Without Recovery, nothing changes: every malformation is a typed CompressionFormatException, as above.
  • A damaged entry still raises CompressionFormatException when read, so a damaged file is never handed back as a good one. The reader records it and moves on to the next entry.
  • ZipReader.Damage lists each ZipDamage in the order found: SkippedBytes, DirectoryUnusable, DamagedEntry or Truncated, with the archive offset, the length, the entry name and a reason. IsRecovered is true when the list isn’t empty.
  • Archive.Open and Archive.Extract take the same option through ArchiveOpenOptions.Zip.
  • Recovery is bounded: MaxRecoveryBytes (1 GiB by default) caps the bytes it skips or scans, and every ArchiveLimits limit still applies.
  • Recovery doesn’t repair or rewrite the archive, and it can’t read encrypted entries without the password.

How candidates are found and checked is in zip recovery.