.NET 11 ZIP imports can fail while an entry is being read, after an earlier record has already reached your application. Read each entry completely, validate every record into a staging collection, and save the batch only after the entire archive succeeds. Catching a CRC exception around a per-entry save loop still leaves earlier writes behind.

The example below imports small key=value records into one existing local JSON file. It was evaluated with SDK 11.0.100-rc.1.26425.128 and runtime 11.0.0-rc.1.26425.128; .NET 11 is prerelease. The file example makes the commit boundary observable without claiming a database transaction or a production upload service.

Stage .NET 11 ZIP imports before saving

Create a console project with the tested SDK, then replace Program.cs with this complete example. The existing JSON file is loaded into a separate dictionary, so adding a staged record does not change the file on disk.

dotnet new console --framework net11.0 -n ZipImport
cd ZipImport
using System.IO.Compression;
using System.Text;
using System.Text.Json;

if (args.Length != 2)
{
    Console.Error.WriteLine("Usage: ZipImport <archive.zip> <records.json>");
    return 2;
}

try
{
    string statePath = Path.GetFullPath(args[1]);
    var staged = JsonSerializer.Deserialize<Dictionary<string, string>>(
        File.ReadAllText(statePath))
        ?? throw new FormatException("Expected a JSON object.");
    using var archive = ZipFile.OpenRead(args[0]);
    if (archive.Entries.Count is 0 or > 16)
        throw new InvalidDataException("Expected 1–16 record entries.");

    long totalBytes = 0;
    foreach (var entry in archive.Entries)
    {
        if (entry.Length is 0 or > 65_536)
            throw new InvalidDataException("Record size exceeds this sample's policy.");
        totalBytes = checked(totalBytes + entry.Length);
        if (totalBytes > 262_144)
            throw new InvalidDataException("Archive exceeds this sample's byte policy.");

        using var input = entry.Open();
        using var buffer = new MemoryStream();
        input.CopyTo(buffer); // Reach EOF before parsing or saving this record.
        string text = new UTF8Encoding(false, true).GetString(buffer.ToArray());
        var record = text.Split('=', 2);
        if (record.Length != 2 || record[0].Length == 0)
            throw new FormatException("Expected key=value.");
        staged.Add(record[0], record[1]); // Reject duplicate and existing keys.
    }

    string pending = statePath + "." + Guid.NewGuid().ToString("N") + ".pending";
    try
    {
        using (var output = new FileStream(
            pending, FileMode.CreateNew, FileAccess.Write, FileShare.None))
        {
            JsonSerializer.Serialize(output, staged);
            output.Flush(flushToDisk: true);
        }
        File.Move(pending, statePath, overwrite: true);
    }
    finally
    {
        if (File.Exists(pending)) File.Delete(pending);
    }
    Console.WriteLine("Committed");
    return 0;
}
catch (Exception ex)
{
    Console.Error.WriteLine($"{ex.GetType().Name}: {ex.Message}");
    return 1;
}

The only persistent write happens after the entry loop finishes. The pending file is created beside the destination, written and closed, then used to replace that destination; a failure before replacement leaves the original in place. Cleanup removes an uncommitted pending file, while the console wrapper reports a failure with a nonzero exit code.

The numeric limits are a small-example policy: at most 16 entries, 64 KiB per record and 256 KiB of declared entry data. They keep this demonstration from accepting arbitrary large input, but they do not bound every cost of opening an archive or reading the existing JSON state. Choose application-specific upload, archive-metadata, state-size and execution-time limits before exposing an importer to untrusted callers.

The record key comes from the payload, and archive filenames are never used as output paths. Empty keys, malformed records and duplicate keys—including keys already present in the saved state—reject the whole batch. This sample treats every entry as a record, so directory entries and empty records are rejected rather than silently skipped.

Why opening an entry is insufficient

CRC32 validation was introduced in .NET 11 Preview 3. The checksum covers the entry data, and validation completes as that data is consumed; successfully creating the archive or opening a stream does not establish that a complete record is valid. A parser that stops after a prefix can therefore miss the failure.

In the local read-boundary experiment, opening a deliberately corrupted stored entry and reading its first byte both succeeded. Reading it to completion raised InvalidDataException; the read loop had already received some bytes before that exception. Keep those bytes provisional even when they look like a valid beginning of a record.

The sample uses CopyTo to reach EOF before decoding and parsing. A streaming parser may avoid that buffer, but it must still finish reading each accepted entry and keep its output staged until the archive-level validation succeeds. Returning from a parser after finding a field or closing an unread stream is not equivalent to this full-read check.

Verify corruption and the saved state

Use Python 3 to create two small stored-entry archives. This fixture changes the last payload byte in the second entry without updating its stored CRC, keeping the first entry valid. The corruption is deliberate test input; it does not simulate every way an archive can be damaged.

from pathlib import Path
from zipfile import ZipFile, ZIP_STORED
import struct

with ZipFile("valid.zip", "w", compression=ZIP_STORED) as archive:
    archive.writestr("first.txt", "first=alpha")
    archive.writestr("second.txt", "second=bravo")

with ZipFile("valid.zip") as archive:
    entry = archive.getinfo("second.txt")
    data = bytearray(Path("valid.zip").read_bytes())
    name_size, extra_size = struct.unpack_from("<HH", data, entry.header_offset + 26)
    payload = entry.header_offset + 30 + name_size + extra_size
    data[payload + entry.file_size - 1] ^= 1
    Path("corrupt-last.zip").write_bytes(data)

Path("records.json").write_text('{"seed":"keep"}', encoding="utf-8")
print("Created valid.zip, corrupt-last.zip and records.json")

Save that script as make-fixtures.py beside the project and run it. The following commands start from the seeded JSON file and attempt the late-corruption import.

python make-fixtures.py
dotnet run -- corrupt-last.zip records.json

Expect a nonzero exit code and an error whose type is InvalidDataException. Although the first record has been staged, records.json must still contain only the seed. Check the stored data directly rather than accepting an error message as evidence of rollback.

python -c "import json; from pathlib import Path; assert json.loads(Path('records.json').read_text()) == {'seed':'keep'}; print('Original state preserved')"
dotnet run -- valid.zip records.json

The valid run should print Committed and leave three keys: seed, first and second. Reset the seed before another valid run; this importer intentionally rejects existing keys instead of overwriting them. That choice makes retries visible, but a real service needs an explicit duplicate or idempotency policy.

The separate seven-case local commit proof compared the staged workflow with an intentionally unsafe per-entry save loop. Each failure case checked the original file bytes and the cleanup result, not just the exception type.

CaseObserved outcomeOriginal state
Valid archiveBoth records committedReplaced once
Corrupt first entryInvalidDataExceptionUnchanged
Corrupt last entryInvalidDataException after one entry validatedUnchanged
Cancellation between entriesOperationCanceledExceptionUnchanged
Malformed last recordFormatExceptionUnchanged
Injected I/O fault before replacementIOExceptionUnchanged
Unsafe per-entry save, corrupt last entryInvalidDataExceptionFirst record already saved

The cancellation and injected-fault rows belong to the companion proof harness, which contains those controls; the console example above has no cancellation interface or fault-injection option. The evidence covers synchronous reads of stored ZIP entries on Ubuntu and one local state file. Compression methods, encrypted archives, another filesystem and a deployed HTTP route were not evaluated by that matrix.

Keep the commit boundary explicit in production

Separate a rejected batch from a failure to store an accepted batch in your logs. Record a batch identifier, the stage that failed and the exception type; avoid logging uploaded record contents by default. Checksum failure may justify obtaining a fresh archive, while a storage error needs diagnosis at the destination. Retrying the same corrupt input will not repair it.

For cancellation, pass the application token into an asynchronous reading path and check it again before starting the commit. The local cancellation proof stops between entries; it does not establish prompt cancellation inside a synchronous CopyTo or interruption of a filesystem replacement. Define the point after which cancellation means the request stopped waiting while the commit outcome still needs reconciliation.

A sibling pending file helps separate preparation from replacement, but this experiment establishes ordinary execution and pre-replace failure behavior only. It does not prove survival of power loss, directory-metadata durability or correctness with concurrent importers. A unique pending filename prevents temporary-name collisions; it does not prevent two writers from loading the same old state and losing one another’s updates. Serialize access or implement a storage-specific concurrency check before using this pattern with multiple writers.

If the destination is SQL, keep validation outside persistent writes and design the final database changes as one transaction. If the destination spans SQL, object storage and outbound messages, choose an explicit coordination and recovery design. The local file result supplies a test pattern for those implementations, not evidence that they already roll back correctly.

CRC32 detects accidental corruption and does not authenticate an archive supplied by an attacker. Keep authorization, trusted provenance and application validation separate, and retain the previous supported importer while evaluating a prerelease runtime. Before rollout, repeat the state-preservation checks against the actual storage implementation and the exact runtime build you intend to deploy.

References

Demo source code: View the complete runnable example on GitHub.

Found this useful? Support more practical developer content.

Author

Practical .NET, Angular, Azure, Blazor, and AI engineering for real-world development.

Write A Comment

Ads Blocker Image Powered by Code Help Pro

Ads Blocker Detected!!!

We have detected that you are using extensions to block ads. Please support us by disabling these ads blocker.

Powered By
Best Wordpress Adblock Detecting Plugin | CHP Adblock