diff --git a/src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs b/src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs index c7eee4d13..e322d7594 100644 --- a/src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs +++ b/src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs @@ -80,7 +80,7 @@ public class CreateZipArchive : CodeActivity try { - using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true); + using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Update, leaveOpen: true); var entryIndex = 0; var compressionLevel = CompressionLevel.Get(context); @@ -122,19 +122,87 @@ public class CreateZipArchive : CodeActivity CompressionLevel compressionLevel) { var binaryContent = await resolver.ResolveAsync(entryContent, context.CancellationToken); - - var entryName = binaryContent.Name?.GetNameAndExtension() + + var entryName = binaryContent.Name?.GetNameAndExtension() ?? string.Format(DefaultEntryNameFormat, entryIndex + 1); + // Get a unique name following Windows convention + entryName = GetUniqueEntryName(zipArchive, entryName); + var archiveEntry = zipArchive.CreateEntry(entryName, compressionLevel); await using var entryStream = archiveEntry.Open(); await binaryContent.Stream.CopyToAsync(entryStream, context.CancellationToken); await entryStream.FlushAsync(context.CancellationToken); - + if (entryContent is not Stream) { await binaryContent.Stream.DisposeAsync(); } } + + private static string GetUniqueEntryName(ZipArchive zipArchive, string originalName) + { + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(originalName); + var extension = Path.GetExtension(originalName); + + var originalExists = false; + var highestIndex = 0; + + foreach (var entry in zipArchive.Entries) + { + if (!entry.Name.Equals(originalName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + originalExists = true; + + var entryNameWithoutExtension = Path.GetFileNameWithoutExtension(entry.Name); + var entryExtension = Path.GetExtension(entry.Name); + + // Only process entries with the same extension + if (!entryExtension.Equals(extension, StringComparison.OrdinalIgnoreCase)) + continue; + + // Check if this entry follows our naming pattern + highestIndex = HighestEntryNameIndex(entryNameWithoutExtension, filenameWithoutExtension, highestIndex); + } + + if (!originalExists) + { + return originalName; + } + + return $"{filenameWithoutExtension}({highestIndex + 1}){extension}"; + } + + private static int HighestEntryNameIndex(string entryNameWithoutExtension, string filenameWithoutExtension, + int highestIndex) + { + if (!entryNameWithoutExtension.StartsWith(filenameWithoutExtension, StringComparison.OrdinalIgnoreCase) || + entryNameWithoutExtension.Length <= filenameWithoutExtension.Length || + entryNameWithoutExtension[filenameWithoutExtension.Length] != '(') + { + return highestIndex; + } + + // Extract the number between parentheses + var closingParenIndex = entryNameWithoutExtension.LastIndexOf(')'); + if (closingParenIndex <= filenameWithoutExtension.Length + 1) + { + return highestIndex; + } + + var indexStr = entryNameWithoutExtension.Substring( + filenameWithoutExtension.Length + 1, + closingParenIndex - filenameWithoutExtension.Length - 1); + + if (int.TryParse(indexStr, out var index)) + { + highestIndex = Math.Max(highestIndex, index); + } + + return highestIndex; + } } \ No newline at end of file diff --git a/src/modules/Elsa.IO/Extensions/ContentTypeExtensions.cs b/src/modules/Elsa.IO/Extensions/ContentTypeExtensions.cs index 45e82a262..45bf158ef 100644 --- a/src/modules/Elsa.IO/Extensions/ContentTypeExtensions.cs +++ b/src/modules/Elsa.IO/Extensions/ContentTypeExtensions.cs @@ -96,27 +96,44 @@ public static class ContentTypeExtensions if (s.Length % 4 != 0) return false; - // Check valid Base64 characters - for (var i = 0; i < s.Length; i++) + // Check padding position and count + var paddingIndex = s.IndexOf('='); + + switch (paddingIndex) + { + // Padding cannot be at index 0 + case 0: + // Padding must be at the end + case > 0 when paddingIndex < s.Length - 2: + // All characters after first '=' must also be '=' + case > 0 when s[paddingIndex..].Any(c => c != '='): + return false; + } + + // Check for valid Base64 characters + for (var i = 0; i < paddingIndex; i++) { var c = s[i]; - - var isValid = + var isValid = c is >= 'A' and <= 'Z' || c is >= 'a' and <= 'z' || c is >= '0' and <= '9' || - c == '+' || c == '/' || c == '='; + c == '+' || c == '/'; if (!isValid) return false; } - // Try actual decoding and roundtrip + // Additional check for short strings that are just lowercase+numbers + // This catches "whatever" and similar false positives + if (s.Length <= 10 && s.All(c => char.IsLower(c) || char.IsDigit(c))) + return false; + + // Try actual decoding try { - var data = Convert.FromBase64String(s); - var reEncoded = Convert.ToBase64String(data); - return s == reEncoded; + _ = Convert.FromBase64String(s); + return true; } catch {