< Summary

Information
Class: Elsa.Http.Services.ZipManager
Assembly: Elsa.Http
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Http/Services/ZipManager.cs
Line coverage
86%
Covered lines: 113
Uncovered lines: 18
Coverable lines: 131
Total lines: 298
Line coverage: 86.2%
Branch coverage
74%
Covered branches: 43
Total branches: 58
Branch coverage: 74.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
CreateAsync()100%44100%
LoadAsync()75%10866.66%
CreateZipArchiveAsync()87.5%88100%
CreateCachedZipBlobAsync()100%22100%
CreateBlob(...)100%22100%
GetDownloadableMetadata(...)50%44100%
GetTempFilePath()100%11100%
TryGetCacheFilename(...)75%4487.5%
IsValidDownloadCorrelationId(...)100%44100%
TryGetSafeBlobPath(...)58.33%161270.58%
TryGetRootedBlobNamespacePath(...)100%22100%
IsCachePathSafe(...)50%7675%
GetFullCacheDirectory()50%22100%
Cleanup(...)100%1150%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Http/Services/ZipManager.cs

#LineLine coverage
 1using System.Buffers;
 2using System.IO.Compression;
 3using Elsa.Common;
 4using Elsa.Http.Options;
 5using FluentStorage.Blobs;
 6using Microsoft.Extensions.Logging;
 7using Microsoft.Extensions.Options;
 8
 9namespace Elsa.Http.Services;
 10
 11/// <summary>
 12/// Provides a helper service for zipping downloadable content.
 13/// </summary>
 14internal class ZipManager
 15{
 16    private const int MaxDownloadCorrelationIdLength = 128;
 117    private static readonly SearchValues<char> DownloadCorrelationIdCharacters = SearchValues.Create("abcdefghijklmnopqr
 18    private readonly ISystemClock _clock;
 19    private readonly IFileCacheStorageProvider _fileCacheStorageProvider;
 20    private readonly IOptions<HttpFileCacheOptions> _fileCacheOptions;
 21    private readonly ILogger<ZipManager> _logger;
 22
 23    /// <summary>
 24    /// Initializes a new instance of the <see cref="ZipManager"/> class.
 25    /// </summary>
 1526    public ZipManager(ISystemClock clock, IFileCacheStorageProvider fileCacheStorageProvider, IOptions<HttpFileCacheOpti
 27    {
 1528        _clock = clock;
 1529        _fileCacheStorageProvider = fileCacheStorageProvider;
 1530        _fileCacheOptions = fileCacheOptions;
 1531        _logger = logger;
 1532    }
 33
 34    public async Task<(Blob, Stream, Action)> CreateAsync(
 35        ICollection<Func<ValueTask<Downloadable>>> downloadables,
 36        bool cache,
 37        string? downloadCorrelationId,
 38        string? downloadAsFilename = default,
 39        string? contentType = default,
 40        CancellationToken cancellationToken = default)
 41    {
 42        // Create a temporary file.
 543        var tempFilePath = GetTempFilePath();
 44
 45        // Create a zip archive from the downloadables.
 546        await CreateZipArchiveAsync(tempFilePath, downloadables, cancellationToken);
 47
 48        // Create a blob with metadata for resuming the download.
 549        var zipBlob = CreateBlob(tempFilePath, downloadAsFilename, contentType);
 50
 51        // If resumable downloads are enabled, cache the file.
 552        if (cache && !string.IsNullOrWhiteSpace(downloadCorrelationId))
 553            await CreateCachedZipBlobAsync(tempFilePath, downloadCorrelationId, downloadAsFilename, contentType, cancell
 54
 555        var zipStream = File.OpenRead(tempFilePath);
 1056        return (zipBlob, zipStream, () => Cleanup(tempFilePath));
 557    }
 58
 59    /// <summary>
 60    /// Loads a cached zip blob for the specified download correlation ID.
 61    /// </summary>
 62    /// <param name="downloadCorrelationId">The download correlation ID.</param>
 63    /// <param name="cancellationToken">An optional cancellation token.</param>
 64    /// <returns>A tuple containing the blob and the stream.</returns>
 65    public async Task<(Blob, Stream)?> LoadAsync(string downloadCorrelationId, CancellationToken cancellationToken = def
 66    {
 1067        if (!TryGetCacheFilename(downloadCorrelationId, out var fileCacheFilename))
 68        {
 669            _logger.LogDebug("Rejected invalid zip download correlation ID");
 670            return null;
 71        }
 72
 473        var fileCacheStorage = _fileCacheStorageProvider.GetStorage();
 474        var blob = await fileCacheStorage.GetBlobAsync(fileCacheFilename, cancellationToken);
 75
 476        if (blob == null)
 077            return null;
 78
 479        if (!TryGetSafeBlobPath(blob.FullPath, fileCacheFilename, out var safeBlobPath))
 80        {
 181            _logger.LogWarning("Rejected unsafe cached zip blob path {FullPath}", blob.FullPath);
 182            return null;
 83        }
 84
 85        // Check if the blob has expired.
 386        var expiresAt = DateTimeOffset.Parse(blob.Metadata["ExpiresAt"]);
 87
 388        if (_clock.UtcNow > expiresAt)
 89        {
 90            // File expired. Try to delete it.
 91            try
 92            {
 093                await fileCacheStorage.DeleteAsync(safeBlobPath, cancellationToken);
 094            }
 095            catch (Exception e)
 96            {
 097                _logger.LogWarning(e, "Failed to delete expired file {FullPath}", blob.FullPath);
 098            }
 99
 0100            return null;
 101        }
 102
 3103        var stream = await fileCacheStorage.OpenReadAsync(safeBlobPath, cancellationToken);
 3104        return (blob, stream);
 10105    }
 106
 107    /// <summary>
 108    /// Creates a zip archive from the specified <see cref="Downloadable"/> instances.
 109    /// </summary>
 110    private async Task CreateZipArchiveAsync(string filePath, IEnumerable<Func<ValueTask<Downloadable>>> downloadables, 
 111    {
 5112        var currentFileIndex = 0;
 113
 114        // Write the zip archive to the temporary file.
 5115        await using var tempFileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read, buf
 116
 5117        using var zipArchive = new ZipArchive(tempFileStream, ZipArchiveMode.Create, true);
 20118        foreach (var downloadableFunc in downloadables)
 119        {
 5120            var downloadable = await downloadableFunc();
 5121            var entryName = !string.IsNullOrWhiteSpace(downloadable.Filename) ? downloadable.Filename : $"file-{currentF
 5122            var entry = zipArchive.CreateEntry(entryName);
 5123            var fileStream = downloadable.Stream;
 5124            await using var entryStream = entry.Open();
 5125            await fileStream.CopyToAsync(entryStream, cancellationToken);
 5126            await entryStream.FlushAsync(cancellationToken);
 5127            entryStream.Close();
 5128            currentFileIndex++;
 5129        }
 5130    }
 131
 132    /// <summary>
 133    /// Creates a cached zip blob for the specified file.
 134    /// </summary>
 135    /// <param name="localPath">The full path of the file to upload.</param>
 136    /// <param name="downloadCorrelationId">The download correlation ID.</param>
 137    /// <param name="downloadAsFilename">The filename to use when downloading the file.</param>
 138    /// <param name="contentType">The content type of the file.</param>
 139    /// <param name="cancellationToken">An optional cancellation token.</param>
 140    private async Task CreateCachedZipBlobAsync(string localPath, string downloadCorrelationId, string? downloadAsFilena
 141    {
 5142        if (!TryGetCacheFilename(downloadCorrelationId, out var fileCacheFilename))
 143        {
 3144            _logger.LogDebug("Rejected invalid zip download correlation ID");
 3145            return;
 146        }
 147
 2148        var fileCacheStorage = _fileCacheStorageProvider.GetStorage();
 2149        var expiresAt = _clock.UtcNow.Add(_fileCacheOptions.Value.TimeToLive);
 2150        var cachedBlob = CreateBlob(fileCacheFilename, downloadAsFilename, contentType, expiresAt);
 2151        await fileCacheStorage.WriteFileAsync(fileCacheFilename, localPath, cancellationToken);
 2152        await fileCacheStorage.SetBlobAsync(cachedBlob, cancellationToken: cancellationToken);
 5153    }
 154
 155    /// <summary>
 156    /// Creates a blob for the specified file.
 157    /// </summary>
 158    /// <param name="fullPath">The full path of the file.</param>
 159    /// <param name="downloadAsFilename">The filename to use when downloading the file.</param>
 160    /// <param name="contentType">The content type of the file.</param>
 161    /// <param name="expiresAt">The date and time at which the file expires.</param>
 162    /// <returns>The blob.</returns>
 163    private Blob CreateBlob(string fullPath, string? downloadAsFilename, string? contentType, DateTimeOffset? expiresAt 
 164    {
 7165        (downloadAsFilename, contentType) = GetDownloadableMetadata(downloadAsFilename, contentType);
 166
 7167        var now = _clock.UtcNow;
 168
 7169        var blob = new Blob(fullPath)
 7170        {
 7171            Metadata =
 7172            {
 7173                ["ContentType"] = contentType,
 7174                ["Filename"] = downloadAsFilename
 7175            },
 7176            CreatedTime = now,
 7177            LastModificationTime = now
 7178        };
 179
 7180        if(expiresAt.HasValue)
 2181            blob.Metadata["ExpiresAt"] = expiresAt.Value.ToString("O");
 182
 7183        return blob;
 184    }
 185
 186    private (string downloadAsFilename, string contentType) GetDownloadableMetadata(string? contentType, string? downloa
 187    {
 7188        contentType = !string.IsNullOrWhiteSpace(contentType) ? contentType : System.Net.Mime.MediaTypeNames.Application
 7189        downloadAsFilename = !string.IsNullOrWhiteSpace(downloadAsFilename) ? downloadAsFilename : "download.zip";
 190
 7191        return (downloadAsFilename, contentType);
 192    }
 193
 194    private string GetTempFilePath()
 195    {
 5196        var tempFileName = Path.GetRandomFileName();
 5197        var tempFilePath = Path.Combine(_fileCacheOptions.Value.LocalCacheDirectory, tempFileName);
 5198        return tempFilePath;
 199    }
 200
 201    private bool TryGetCacheFilename(string downloadCorrelationId, out string fileCacheFilename)
 202    {
 15203        fileCacheFilename = default!;
 204
 15205        if (!IsValidDownloadCorrelationId(downloadCorrelationId))
 9206            return false;
 207
 6208        var candidateFilename = $"{downloadCorrelationId}.tmp";
 209
 6210        if (!IsCachePathSafe(candidateFilename))
 0211            return false;
 212
 6213        fileCacheFilename = candidateFilename;
 6214        return true;
 215    }
 216
 217    private static bool IsValidDownloadCorrelationId(string value)
 218    {
 15219        if (string.IsNullOrWhiteSpace(value) || value.Length > MaxDownloadCorrelationIdLength)
 2220            return false;
 221
 13222        return value.AsSpan().IndexOfAnyExcept(DownloadCorrelationIdCharacters) < 0;
 223    }
 224
 225    private bool TryGetSafeBlobPath(string path, string expectedFilename, out string safeBlobPath)
 226    {
 4227        safeBlobPath = default!;
 228
 4229        if (string.IsNullOrWhiteSpace(path))
 0230            return false;
 231
 4232        if (TryGetRootedBlobNamespacePath(path, expectedFilename, out safeBlobPath))
 2233            return true;
 234
 2235        if (!Path.IsPathRooted(path))
 236        {
 0237            if (!IsCachePathSafe(path))
 0238                return false;
 239
 0240            safeBlobPath = path;
 0241            return true;
 242        }
 243
 2244        var fullPath = Path.GetFullPath(path);
 2245        var fullCacheDirectory = GetFullCacheDirectory();
 2246        var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
 2247        if (!fullPath.StartsWith(fullCacheDirectory, comparison))
 1248            return false;
 249
 1250        safeBlobPath = fullPath;
 1251        return true;
 252    }
 253
 254    private static bool TryGetRootedBlobNamespacePath(string path, string expectedFilename, out string safeBlobPath)
 255    {
 4256        safeBlobPath = default!;
 257
 258        // FluentStorage directory blobs use "/file" as a blob namespace path.
 4259        var blobPath = path.Replace('\\', '/');
 4260        if (blobPath != $"/{expectedFilename}")
 2261            return false;
 262
 2263        safeBlobPath = expectedFilename;
 2264        return true;
 265    }
 266
 267    private bool IsCachePathSafe(string path)
 268    {
 6269        if (string.IsNullOrWhiteSpace(path))
 0270            return false;
 271
 6272        if (Path.IsPathRooted(path))
 0273            return false;
 274
 6275        var fullCacheDirectory = GetFullCacheDirectory();
 6276        var fullPath = Path.GetFullPath(Path.Join(fullCacheDirectory, path));
 6277        var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
 6278        return fullPath.StartsWith(fullCacheDirectory, comparison);
 279    }
 280
 281    private string GetFullCacheDirectory()
 282    {
 8283        var cacheDirectory = Path.GetFullPath(_fileCacheOptions.Value.LocalCacheDirectory);
 8284        return Path.EndsInDirectorySeparator(cacheDirectory) ? cacheDirectory : cacheDirectory + Path.DirectorySeparator
 285    }
 286
 287    private void Cleanup(string filePath)
 288    {
 289        try
 290        {
 5291            File.Delete(filePath);
 5292        }
 0293        catch (Exception e)
 294        {
 0295            _logger.LogWarning(e, "Failed to delete temporary file {TempFilePath}", filePath);
 0296        }
 5297    }
 298}