Serving Files From a ZIP Archive in .NET 6

In recent months, some co-workers and I have slowly started to transform Jobbr, a non-invasive .NET job server, from a .NET Framework to a .NET 6 project. We tried to keep as much as possible in place, and with that also came the serving of static files directly from a ZIP archive. Due to migrating from ASP.NET to ASP.NET Core, we had to change how this was written and I’ll now walk you through the new implementation.

Singapore's Gardens by the Bay as seen from Marina Bay Sands at night
View from Marina Bay Sands onto the Gardens by the Bay from my trip to Singapore

Reading the Source

As the basis we took the SharpFileSystem library, which implements a Virtual File System (VFS) for different kinds of sources, including one for ZIP archives. The first problem with that library is, that it hasn’t been updated for .NET Standard 2.0 or any other version compatible with .NET 6. Luckily, someone else ran into the same issue, forked the project and published new packages under SharpCoreFileSystem on NuGet.org.

Bridging the Gap

ASP.NET Core’s static file hosting requires an implementation of IFileProvider, which SharpCoreFileSystem unfortunately doesn’t provide, meaning we’ll have to write our own adapter.

IFileProvider requires the implementation of three methods:

  • IDirectoryContents GetDirectoryContents(string subpath)
  • IFileInfo GetFileInfo(string subpath)
  • IChangeToken Watch(string filter)

As we’re not interested in writing of expecting others to change the files, the decision to not implement the Watch method was done quite quickly. Leaving us with GetDirectoryContents and GetFileInfo, for both of which, we have to implement a custom return type.

Provide the File Info

One of the tricky parts is, that IFileSystem doesn’t exactly provide the information, we’d need to implement the IFileInfo interface, so we’ll just have to improvise a bit. For example, we take the current time for the LastModified date, and don’t return a PhysicalPath, as there isn’t one.

public class ZipFileInfo : IFileInfo
{
    private readonly IFileSystem _fileSystem;
    private static readonly DateTime AssemblyLastModified = DateTime.UtcNow;
    private FileSystemPath _path;

    public ZipFileInfo(FileSystemPath path, IFileSystem fileSystem)
    {
        _fileSystem = fileSystem;
        _path = path;

        if (_fileSystem.Exists(path))
        {
            using (var stream = _fileSystem.OpenFile(path, FileAccess.Read))
            {
                Length = stream.ReadAllBytes().Length;
            }
        }
    }

    public Stream CreateReadStream()
    {
        return _fileSystem.OpenFile(_path, FileAccess.Read);
    }

    public bool Exists => _fileSystem.Exists(_path);

    DateTimeOffset IFileInfo.LastModified => LastModified;

    public long Length { get; }

    public string PhysicalPath => null;

    public string Name => _path.EntityName;

    public DateTime LastModified => AssemblyLastModified;

    public bool IsDirectory => _path.IsDirectory;
}

In the constructor we take a FileSystemPath, which will tell what file is actually being accessed, and the IFileSystem, from which we can read the file’s content. To determine the length of the file, we simply read the whole file once and store the number of bytes read, as there isn’t an easier way to get that information. If you have large files in your archive, maybe don’t do this, and/or consider not reading files directly from a ZIP archive. CreateReadStream open the file in read-mode and returns a Stream, essentially transferring the ownership of the Stream to the consumer. The rest of the properties seem quite straight forward and don’t need additional comments.

What’s in the Directory?

IDirectoryContents on the other hand expects an enumerator implementation. The heavy lifting is essentially done by SharpFileSystem’s IFileSystem and the above described IFileInfo implementation.

public class ZipDirectoryContents : IDirectoryContents
{
    private readonly IFileSystem _fileSystem;
    private readonly FileSystemPath _path;

    public ZipDirectoryContents(FileSystemPath path, IFileSystem fileSystem)
    {
        _fileSystem = fileSystem;
        _path = path;
    }

    public IEnumerator<IFileInfo> GetEnumerator()
    {
        return _fileSystem.GetEntities(_path)
                          .Select(p => new ZipFileInfo(p, _fileSystem))
                          .GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

    public bool Exists => _fileSystem.Exists(_path);
}

The constructor takes again the FileSystemPath to the wanted directory and the related IFileSystem. The we fetch all the file system entities, utilize the power of LINQ to create new IFileInfo instances and make this available as enumerator, reliving us from writing anything more complicated.

Putting it all together

With these return types ready to go, we can finally implement the IFileProvider interface… except, we first have to solve the issue, where reading the ZIP archive from disk, requires the file handle to remain open for as long as the NetZipArchiveFileSystem instance is being used. What we noticed is, that the provider is instantiated multiple times by ASP.NET Core and/or it might be some bad coding from our side, either way, we can’t assume that it will only ever create a single instance. The simplest way out was chosen for now (we’re happy for additional feedback) the FileStream instance was marked as static, so it would remain the same across different instances of the ZipFileContentProvider.

public class ZipFileContentProvider : IFileProvider, IDisposable
{
    private static FileStream _fileStream;
    private readonly NetZipArchiveFileSystem _fileSystem;

    public ZipFileContentProvider(string zipFileName)
    {
        var zipPath = Path.Combine(Directory.GetCurrentDirectory(), zipFileName);

        // Share file access across multiple instances
        if (_fileStream == null)
        {
            _fileStream = File.Open(zipPath, FileMode.Open, FileAccess.ReadWrite);
        }

        _fileSystem = NetZipArchiveFileSystem.Open(_fileStream);
    }

    public IFileInfo GetFileInfo(string subpath)
    {
        if (subpath != "/" && _fileSystem.Exists(subpath))
        {
            return new ZipFileInfo(subpath, _fileSystem);
        }

        return new ZipFileInfo("/index.html", _fileSystem);
    }

    public IDirectoryContents GetDirectoryContents(string subpath)
    {
        if (_fileSystem.Exists(subpath))
        {
            return new ZipDirectoryContents(subpath, _fileSystem);
        }

        return new NotFoundDirectoryContents();
    }

    public IChangeToken Watch(string filter)
    {
        throw new NotImplementedException();
    }

    public void Dispose()
    {
        _fileSystem?.Dispose();
    }
}

The GetFileInfo and GetDirectoryContents might look different for you, depending on whether you want to capture different path or error scenarios. One remark regarding SharpFileSystem, you can’t pass it the root (/) directory, which is why we implement a fallback to /index.html. As mentioned Watch wasn’t implemented. Finally, due multiple instance using the FileStream, we can’t easily dispose it, so it was left as exercise to the GC.

Spinning Up the Server

The new IFileProvider implementation can now easily be used with any WebHost or similar, by attaching it as FileServer. Here’s a simple usage example:

var zipFileContentProvide = new ZipFileContentProvide("some-app.zip");
// ...
var webHost = new WebHostBuilder()
    // ...
    .Configure(app =>
    {
        // ...
        app.UseFileServer(new FileServerOptions
        {
            EnableDefaultFiles = true,
            FileProvider = zipFileContentProvide,
            StaticFileOptions =
            {
                FileProvider = zipFileContentProvide,
                ServeUnknownFileTypes = true
            },
            DefaultFilesOptions =
            {
                DefaultFileNames = new[]
                {
                    "index.html"
                }
            }
        });
    })
    .Build();
// ...
webHost.Start();

This basic case was enough for us, but there is of course a lot of potential for customization, so do play around and if you think, there’s room for improvement in this example, please let me know.

Leave a Comment

Your email address will not be published. Required fields are marked *

 

This site uses Akismet to reduce spam. Learn how your comment data is processed.