From 88c14a8ba0978cae3deebc64f2d0ac01cf98dbe2 Mon Sep 17 00:00:00 2001 From: Roland Banks Date: Sun, 6 Sep 2026 09:18:57 +0700 Subject: [PATCH] fix: permit Zip64 when saving, so large workbooks save instead of failing at the last step ZipPackage.Save created its ZipOutputStream setting only CompressionLevel, so EnableZip64 kept the constructor default of Zip64Option.Never. That capped every workbook at the classic ZIP limits: 4 GB of size or offset, and 65,534 entries. A workbook past either limit threw during the save, after all of its content had been built. Found when a report against a 25,663-resource estate ran for eleven hours and then lost its spreadsheet to: ZipException: Compressed or Uncompressed size, or offset exceeds the maximum value. Consider setting the UseZip64WhenSaving property on the ZipFile instance. at ZipEntry.SetZip64Flags() ... at ZipPackage.Save(Stream) ... at ExcelPackage.Save() AsNecessary rather than Always, so a workbook that fits the classic format is still written in it and stays readable by older tools. Only a workbook that would otherwise fail switches. Zip64Tests covers the entry-count limit, which is reachable in a unit test, and pins the Zip64Option.Never default so those tests are demonstrably about the real defect. The 4 GB limit shares that one cause. The full suite is unchanged by this: 156 passed and 5 failed both with and without the fix, those 5 being pre-existing failures from missing Workbooks test data. Also restores Directory.Packages.props. The projects use central package management with bare PackageReference items, but the file had been added and then removed, so nothing restored (NU1015) and the repository did not build at all. The removed version listed xunit.v3 and a coverage package, neither of which this repository uses; versions here are recovered from the last commits that still carried them. The solution now builds with 0 errors, including SampleApp and SampleWebApp. Co-Authored-By: Claude Opus 5 --- Directory.Packages.props | 24 +++ PanoramicData.EPPlus.Test/Zip64Tests.cs | 140 ++++++++++++++++++ PanoramicData.EPPlus/Packaging/ZipPackage.cs | 11 +- .../PanoramicData.EPPlus.csproj | 2 +- 4 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 Directory.Packages.props create mode 100644 PanoramicData.EPPlus.Test/Zip64Tests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 000000000..af4fec9c2 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,24 @@ + + + + true + + + + + + + + + + + + + + + + + + + + diff --git a/PanoramicData.EPPlus.Test/Zip64Tests.cs b/PanoramicData.EPPlus.Test/Zip64Tests.cs new file mode 100644 index 000000000..855b5abbd --- /dev/null +++ b/PanoramicData.EPPlus.Test/Zip64Tests.cs @@ -0,0 +1,140 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml.Packaging.DotNetZip; +using OfficeOpenXml.Packaging.Ionic.Zip; +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; + +namespace PanoramicData.EPPlus.Test; + +/// +/// MagicSuite: a LogicMonitor Health Check against a 25,663-resource portal failed after eleven +/// hours with +/// +/// ZipException: Compressed or Uncompressed size, or offset exceeds the maximum value. +/// Consider setting the UseZip64WhenSaving property on the ZipFile instance. +/// at ZipEntry.SetZip64Flags() +/// at ZipOutputStream.PutNextEntry(String) +/// at ZipPackage.Save(Stream) +/// at ExcelPackage.Save() +/// +/// The workbook's content had been built successfully; only the save failed, so eleven hours of +/// report was lost at the final step. +/// +/// Cause: ZipPackage.Save creates its ZipOutputStream setting only CompressionLevel, so +/// EnableZip64 keeps the constructor default of Zip64Option.Never (ZipOutputStream.cs). That caps +/// every workbook EPPlus writes at the classic ZIP limits: 4 GB of size or offset, and 65,534 +/// entries. +/// +[TestClass] +public class Zip64Tests +{ + private const int EntriesBeyondTheLimit = 65_600; + + /// + /// These tests drive ZipOutputStream directly rather than through ExcelPackage, so the + /// code-page provider that IBM437 relies on has not already been registered for us. + /// + [ClassInitialize] + public static void ClassInitialize(TestContext _) + => Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + /// + /// The 4 GB limit cannot be exercised in a unit test in reasonable time, but it and the entry + /// limit share one cause: the Zip64Option.Never default. Pinning that default keeps the tests + /// below demonstrably about the real defect. + /// + [TestMethod] + public void ZipOutputStream_Default_IsZip64Never() + { + using var stream = new MemoryStream(); + using var zip = new ZipOutputStream(stream, leaveOpen: true); + + Assert.AreEqual(Zip64Option.Never, zip.EnableZip64); + } + + /// + /// Reproduces the production failure through the entry-count limit, which is reachable quickly. + /// + [TestMethod] + public void ZipOutputStream_Zip64Never_FailsOnceEntryCountExceeds65534() + { + using var stream = new MemoryStream(); + + var exception = Assert.ThrowsException(() => + { + using var zip = new ZipOutputStream(stream, leaveOpen: true) + { + EnableZip64 = Zip64Option.Never + }; + WriteEntries(zip, EntriesBeyondTheLimit); + }); + + StringAssert.Contains(exception.Message, "EnableZip64"); + } + + /// + /// The proposed fix: the same workload succeeds, and the result is a readable archive holding + /// every entry rather than merely a stream of bytes that did not throw. + /// + [TestMethod] + public void ZipOutputStream_Zip64AsNecessary_WritesAndReadsBackMoreThan65534Entries() + { + using var stream = new MemoryStream(); + + using (var zip = new ZipOutputStream(stream, leaveOpen: true) + { + EnableZip64 = Zip64Option.AsNecessary + }) + { + WriteEntries(zip, EntriesBeyondTheLimit); + } + + stream.Position = 0; + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + Assert.AreEqual(EntriesBeyondTheLimit, archive.Entries.Count); + Assert.IsNotNull(archive.GetEntry("entry-0.txt")); + Assert.IsNotNull(archive.GetEntry($"entry-{EntriesBeyondTheLimit - 1}.txt")); + } + + /// + /// AsNecessary rather than Always matters, so this checks the no-regression claim. The bytes + /// are not identical, because the central directory differs, but a small archive must remain + /// readable by a standard reader and hold exactly the same entries. + /// + [TestMethod] + public void ZipOutputStream_Zip64AsNecessary_LeavesSmallArchivesReadable() + { + var never = ReadBackEntryNames(Zip64Option.Never); + var asNecessary = ReadBackEntryNames(Zip64Option.AsNecessary); + + CollectionAssert.AreEqual(never, asNecessary, + "A small archive should hold the same entries whether or not Zip64 is permitted."); + } + + private static string[] ReadBackEntryNames(Zip64Option option) + { + using var stream = new MemoryStream(); + using (var zip = new ZipOutputStream(stream, leaveOpen: true) { EnableZip64 = option }) + { + WriteEntries(zip, 3); + } + + stream.Position = 0; + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + return [.. archive.Entries.Select(e => e.FullName).OrderBy(n => n, StringComparer.Ordinal)]; + } + + private static void WriteEntries(ZipOutputStream zip, int count) + { + var payload = Encoding.UTF8.GetBytes("x"); + for (var i = 0; i < count; i++) + { + zip.PutNextEntry($"entry-{i}.txt"); + zip.Write(payload, 0, payload.Length); + } + } +} diff --git a/PanoramicData.EPPlus/Packaging/ZipPackage.cs b/PanoramicData.EPPlus/Packaging/ZipPackage.cs index 729950cf0..bccc02adb 100644 --- a/PanoramicData.EPPlus/Packaging/ZipPackage.cs +++ b/PanoramicData.EPPlus/Packaging/ZipPackage.cs @@ -277,7 +277,16 @@ internal void Save(Stream stream) var enc = Encoding.UTF8; ZipOutputStream os = new(stream, true) { - CompressionLevel = (DotNetZip.Zlib.CompressionLevel)_compression + CompressionLevel = (DotNetZip.Zlib.CompressionLevel)_compression, + + // Without this, EnableZip64 keeps its constructor default of Zip64Option.Never, which + // caps every workbook at the classic ZIP limits: 4 GB of size or offset, and 65,534 + // entries. A large workbook then throws while saving, after all its content has been + // built, so the work is lost at the final step. + // + // AsNecessary rather than Always: a workbook that fits the classic format is still + // written in it, so existing files and older readers are unaffected. + EnableZip64 = Ionic.Zip.Zip64Option.AsNecessary }; /**** ContentType****/ var entry = os.PutNextEntry("[Content_Types].xml"); diff --git a/PanoramicData.EPPlus/PanoramicData.EPPlus.csproj b/PanoramicData.EPPlus/PanoramicData.EPPlus.csproj index daecdafa0..735ded60d 100644 --- a/PanoramicData.EPPlus/PanoramicData.EPPlus.csproj +++ b/PanoramicData.EPPlus/PanoramicData.EPPlus.csproj @@ -19,7 +19,7 @@ # Panoramic Data's EPPlus Free Edition # LGPL License continue - Added AddPicture(name, imageBytes, widthPixels, heightPixels) - add pictures from raw image bytes without System.Drawing, enabling picture support on platforms where GDI+ is unavailable (e.g. Linux). Fixed Dispose for pictures created without an Image. + Large workbooks can now be saved. A workbook that exceeded the classic ZIP limits, being 4 GB of size or offset or 65,534 internal entries, previously threw while saving, after all of its content had been built. Zip64 is now used automatically when, and only when, a workbook needs it, so files that fit the classic format are written exactly as before and remain readable by older tools. False OpenOfficeXml.Rimland.snk