diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7ef4a341c2..4d9abfdabf 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -160,6 +160,15 @@ part name on each pack run; the normalizer rewrites that part to matching content-type and relationship references, and gives ZIP entries stable timestamps. This is the package reproducibility boundary for `.nupkg` and `.snupkg` archives (#2756). +Before rewriting, the normalizer rejects packages with more than 4096 ZIP +entries, any entry above 128 MiB uncompressed, total uncompressed content above +512 MiB, or XML reference text above 16 MiB so crafted packages cannot force +unbounded normalization work (#2892). +It also rejects unsafe ZIP entry names before creating the destination archive: +absolute paths, Windows drive roots, backslash separators, empty path segments, +parent-directory segments, empty normalized names, and destination names that +collide after path normalization are not preserved into normalized packages +(#2894). When you intentionally update a dependency (or add a new direct `PackageReference`), regenerate the lock files locally and commit the diff in the same change: @@ -2048,6 +2057,14 @@ release の `dotnet publish`(RID ごと)と `dotnet pack`(NuGet パッケ `package/services/metadata/core-properties/core-properties.psmdcp` に書き換え、 対応する content-type / relationship 参照も更新し、ZIP entry timestamp を固定します。 これが `.nupkg` / `.snupkg` archive の package 再現性境界です (#2756)。 +書き換え前に、normalizer は 4096 を超える ZIP entry、128 MiB を超える +uncompressed entry、512 MiB を超える合計 uncompressed content、または +16 MiB を超える XML 参照テキストを持つ package を拒否し、細工された +package が無制限の normalize 作業を強制できないようにします (#2892)。 +また destination archive を作る前に unsafe な ZIP entry 名も拒否します。 +absolute path、Windows drive root、backslash separator、空の path segment、 +parent-directory segment、空に正規化される名前、path 正規化後に衝突する +destination 名は、normalized package に保持されません (#2894)。 依存を意図的に更新する(あるいは直接 `PackageReference` を追加する)場合は、ローカルで lock ファイルを再生成し、同じ変更でコミットしてください: diff --git a/changelog.d/unreleased/2892.security.md b/changelog.d/unreleased/2892.security.md new file mode 100644 index 0000000000..85a9db9859 --- /dev/null +++ b/changelog.d/unreleased/2892.security.md @@ -0,0 +1,18 @@ +--- +category: security +issues: + - 2892 +affected: + - tools/CodeIndex.PackageNormalize/CodeIndex.PackageNormalize.csproj + - tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs + - tests/CodeIndex.Tests/ReleaseWorkflowTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Package normalization now caps ZIP resource usage (#2892)** — `CodeIndex.PackageNormalize` now rejects packages that exceed documented ZIP entry-count, per-entry, total-uncompressed, or XML-text limits before rewriting release artifacts. + +## 日本語 + +- **Package normalize が ZIP resource 使用量を上限で制限するようになりました (#2892)** — `CodeIndex.PackageNormalize` は release artifact を書き換える前に、文書化された ZIP entry 数、entry 単位、合計 uncompressed size、XML text の上限を超える package を拒否します。 diff --git a/changelog.d/unreleased/2894.security.md b/changelog.d/unreleased/2894.security.md new file mode 100644 index 0000000000..1712b2b6e7 --- /dev/null +++ b/changelog.d/unreleased/2894.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2894 +affected: + - tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs + - tests/CodeIndex.Tests/ReleaseWorkflowTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Package normalization now rejects unsafe ZIP entry names (#2894)** — `CodeIndex.PackageNormalize` validates source and destination ZIP entry names before rewriting, preventing absolute paths, parent traversal, backslash separators, empty path segments, and normalized duplicate destination names from being preserved. + +## 日本語 + +- **Package normalize が unsafe な ZIP entry 名を拒否するようになりました (#2894)** — `CodeIndex.PackageNormalize` は書き換え前に source / destination の ZIP entry 名を検証し、absolute path、parent traversal、backslash separator、空の path segment、正規化後に重複する destination 名が保持されないようにします。 diff --git a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs index 294fd4a554..189d088491 100644 --- a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +++ b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs @@ -146,6 +146,172 @@ public void PackageNormalizer_RewritesRandomCorePropertiesPartDeterministically( } } + [Fact] + public void PackageNormalizer_RejectsPackageThatExceedsEntryCountLimit() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsPackageThatExceedsEntryCountLimit)); + try + { + var packagePath = Path.Combine(projectRoot, "too-many-entries.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + ("payload.txt", "ok")); + + var limits = PackageNormalizeLimits.Default with { MaxEntryCount = 1 }; + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath, limits)); + Assert.Contains("2 ZIP entries", exception.Message); + Assert.Contains("limit of 1", exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_RejectsEntryThatExceedsPerEntryLimit() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsEntryThatExceedsPerEntryLimit)); + try + { + var packagePath = Path.Combine(projectRoot, "large-entry.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + ("payload.bin", "123456")); + + var limits = PackageNormalizeLimits.Default with + { + MaxEntryUncompressedBytes = 5, + MaxTotalUncompressedBytes = 100, + }; + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath, limits)); + Assert.Contains("payload.bin", exception.Message); + Assert.Contains("per-entry limit of 5 bytes", exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_RejectsPackageThatExceedsTotalUncompressedLimit() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsPackageThatExceedsTotalUncompressedLimit)); + try + { + var packagePath = Path.Combine(projectRoot, "large-total.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + ("a.txt", "1234"), + ("b.txt", "5678")); + + var limits = PackageNormalizeLimits.Default with + { + MaxEntryUncompressedBytes = 10, + MaxTotalUncompressedBytes = 6, + }; + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath, limits)); + Assert.Contains("b.txt", exception.Message); + Assert.Contains("uncompressed size exceed the limit of 6 bytes", exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_RejectsXmlEntryThatExceedsTextLimit() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsXmlEntryThatExceedsTextLimit)); + try + { + var packagePath = Path.Combine(projectRoot, "large-xml.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + ("[Content_Types].xml", "123456")); + + var limits = PackageNormalizeLimits.Default with + { + MaxEntryUncompressedBytes = 100, + MaxTotalUncompressedBytes = 100, + MaxXmlTextChars = 5, + }; + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath, limits)); + Assert.Contains("[Content_Types].xml", exception.Message); + Assert.Contains("text limit of 5 characters", exception.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Theory] + [InlineData("/payload.txt", "must be a relative path")] + [InlineData("C:/payload.txt", "must be a relative path")] + [InlineData("./C:/payload.txt", "must be a relative path")] + [InlineData("../payload.txt", "must not contain parent-directory segments")] + [InlineData("folder\\payload.txt", "must use '/' separators")] + [InlineData("folder//payload.txt", "must not contain empty path segments")] + public void PackageNormalizer_RejectsUnsafeZipEntryNames(string unsafeEntryName, string expectedMessage) + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsUnsafeZipEntryNames)); + try + { + var packagePath = Path.Combine(projectRoot, "unsafe-name.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + (unsafeEntryName, "payload")); + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath)); + Assert.Contains(unsafeEntryName, exception.Message); + Assert.Contains(expectedMessage, exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_RejectsDestinationNamesThatNormalizeToDuplicates() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsDestinationNamesThatNormalizeToDuplicates)); + try + { + var packagePath = Path.Combine(projectRoot, "duplicate-normalized-name.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + ("docs/readme.txt", "one"), + ("docs/./readme.txt", "two")); + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath)); + Assert.Contains("docs/./readme.txt", exception.Message); + Assert.Contains("duplicate destination name docs/readme.txt", exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ReleaseWorkflow_PublishesOfficialContainerImage() { @@ -212,6 +378,13 @@ private static void CreateMinimalNuGetPackage(string packagePath, string corePro """); } + private static void CreatePackageWithEntries(string packagePath, params (string EntryName, string Content)[] entries) + { + using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); + foreach (var entry in entries) + WriteZipEntry(archive, entry.EntryName, entry.Content); + } + private static void WriteZipEntry(ZipArchive archive, string entryName, string content) { var entry = archive.CreateEntry(entryName); diff --git a/tools/CodeIndex.PackageNormalize/CodeIndex.PackageNormalize.csproj b/tools/CodeIndex.PackageNormalize/CodeIndex.PackageNormalize.csproj index 2b8d4aed18..839391dd3c 100644 --- a/tools/CodeIndex.PackageNormalize/CodeIndex.PackageNormalize.csproj +++ b/tools/CodeIndex.PackageNormalize/CodeIndex.PackageNormalize.csproj @@ -8,4 +8,8 @@ false + + + + diff --git a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs index b8dca1d0c0..d4398e550c 100644 --- a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs +++ b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs @@ -30,8 +30,14 @@ public static class PackageCorePropertiesNormalizer private static readonly DateTimeOffset StableZipTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); public static void NormalizePackage(string packagePath) + { + NormalizePackage(packagePath, PackageNormalizeLimits.Default); + } + + internal static void NormalizePackage(string packagePath, PackageNormalizeLimits limits) { ArgumentException.ThrowIfNullOrWhiteSpace(packagePath); + limits.Validate(); var fullPath = Path.GetFullPath(packagePath); var tempPath = fullPath + ".normalize-tmp"; @@ -40,17 +46,13 @@ public static void NormalizePackage(string packagePath) using (var sourceStream = File.Open(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var sourceArchive = new ZipArchive(sourceStream, ZipArchiveMode.Read, leaveOpen: false)) - using (var destinationStream = File.Open(tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) - using (var destinationArchive = new ZipArchive(destinationStream, ZipArchiveMode.Create, leaveOpen: false)) { - var corePropertiesEntries = sourceArchive.Entries - .Where(entry => IsCorePropertiesPart(entry.FullName)) - .ToArray(); + var originalCorePropertiesPath = ValidateSourceArchive(sourceArchive, packagePath, limits); + ValidateEntryNamesBeforeRewrite(sourceArchive, originalCorePropertiesPath); - if (corePropertiesEntries.Length != 1) - throw new InvalidOperationException($"Expected exactly one NuGet core-properties part in {packagePath}, found {corePropertiesEntries.Length}."); - - var originalCorePropertiesPath = corePropertiesEntries[0].FullName; + using var destinationStream = File.Open(tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None); + using var destinationArchive = new ZipArchive(destinationStream, ZipArchiveMode.Create, leaveOpen: false); + var readBudget = new PackageNormalizeReadBudget(limits); var usedNames = new HashSet(StringComparer.Ordinal); foreach (var sourceEntry in sourceArchive.Entries) @@ -66,18 +68,18 @@ public static void NormalizePackage(string packagePath) destinationEntry.LastWriteTime = StableZipTimestamp; destinationEntry.ExternalAttributes = sourceEntry.ExternalAttributes; - using var sourceEntryStream = sourceEntry.Open(); + using var rawSourceEntryStream = sourceEntry.Open(); + using var sourceEntryStream = new BudgetedEntryReadStream(rawSourceEntryStream, sourceEntry, readBudget); using var destinationEntryStream = destinationEntry.Open(); if (NeedsXmlReferenceRewrite(sourceEntry.FullName)) { - using var reader = new StreamReader(sourceEntryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: false); using var writer = new StreamWriter(destinationEntryStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), leaveOpen: false); - writer.Write(RewriteCorePropertiesReferences(reader.ReadToEnd(), originalCorePropertiesPath)); + writer.Write(RewriteCorePropertiesReferences(ReadXmlEntryText(sourceEntry, sourceEntryStream, limits), originalCorePropertiesPath)); } else { - sourceEntryStream.CopyTo(destinationEntryStream); + CopyEntry(sourceEntryStream, destinationEntryStream); } } } @@ -85,6 +87,261 @@ public static void NormalizePackage(string packagePath) File.Move(tempPath, fullPath, overwrite: true); } + private static string ValidateSourceArchive(ZipArchive sourceArchive, string packagePath, PackageNormalizeLimits limits) + { + if (sourceArchive.Entries.Count > limits.MaxEntryCount) + throw new InvalidOperationException($"Package {packagePath} has {sourceArchive.Entries.Count} ZIP entries, which exceeds the limit of {limits.MaxEntryCount}."); + + string? originalCorePropertiesPath = null; + var corePropertiesEntryCount = 0; + long totalUncompressedBytes = 0; + + foreach (var sourceEntry in sourceArchive.Entries) + { + ValidateEntrySize(sourceEntry, limits); + + if (totalUncompressedBytes > limits.MaxTotalUncompressedBytes - sourceEntry.Length) + { + throw new InvalidOperationException( + $"ZIP entry {sourceEntry.FullName} makes package uncompressed size exceed the limit of {limits.MaxTotalUncompressedBytes} bytes."); + } + + totalUncompressedBytes += sourceEntry.Length; + + if (!IsCorePropertiesPart(sourceEntry.FullName)) + continue; + + corePropertiesEntryCount++; + originalCorePropertiesPath = sourceEntry.FullName; + } + + if (corePropertiesEntryCount != 1) + throw new InvalidOperationException($"Expected exactly one NuGet core-properties part in {packagePath}, found {corePropertiesEntryCount}."); + + return originalCorePropertiesPath!; + } + + private static void ValidateEntryNamesBeforeRewrite(ZipArchive sourceArchive, string originalCorePropertiesPath) + { + var normalizedDestinationNames = new HashSet(StringComparer.Ordinal); + + foreach (var sourceEntry in sourceArchive.Entries) + { + ValidateZipEntryName(sourceEntry.FullName, "source"); + + var destinationName = sourceEntry.FullName == originalCorePropertiesPath + ? CanonicalCorePropertiesPath + : sourceEntry.FullName; + var normalizedDestinationName = ValidateZipEntryName(destinationName, "destination"); + + if (!normalizedDestinationNames.Add(normalizedDestinationName)) + { + throw new InvalidOperationException( + $"ZIP entry {destinationName} normalizes to duplicate destination name {normalizedDestinationName}."); + } + } + } + + private static string ValidateZipEntryName(string entryName, string role) + { + if (entryName.Length == 0) + throw new InvalidOperationException($"ZIP {role} entry name must not be empty."); + + if (entryName.Contains('\\')) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must use '/' separators, not backslashes."); + + if (entryName.Contains('\0')) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must not contain NUL characters."); + + if (entryName[0] == '/' || StartsWithWindowsDrivePrefix(entryName)) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must be a relative path."); + + var segments = entryName.Split('/'); + var normalizedSegments = new List(segments.Length); + foreach (var segment in segments) + { + if (segment.Length == 0) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must not contain empty path segments."); + + if (segment == "..") + throw new InvalidOperationException($"ZIP {role} entry {entryName} must not contain parent-directory segments."); + + if (segment == ".") + continue; + + normalizedSegments.Add(segment); + } + + if (normalizedSegments.Count == 0) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must not normalize to an empty path."); + + var normalizedName = string.Join('/', normalizedSegments); + if (normalizedName[0] == '/' || StartsWithWindowsDrivePrefix(normalizedName)) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must be a relative path."); + + return normalizedName; + } + + private static bool StartsWithWindowsDrivePrefix(string entryName) + { + return entryName.Length >= 2 + && entryName[1] == ':' + && ((entryName[0] >= 'A' && entryName[0] <= 'Z') || (entryName[0] >= 'a' && entryName[0] <= 'z')); + } + + private static void ValidateEntrySize(ZipArchiveEntry sourceEntry, PackageNormalizeLimits limits) + { + if (sourceEntry.Length > limits.MaxEntryUncompressedBytes) + { + throw new InvalidOperationException( + $"ZIP entry {sourceEntry.FullName} is {sourceEntry.Length} bytes uncompressed, which exceeds the per-entry limit of {limits.MaxEntryUncompressedBytes} bytes."); + } + } + + private static string ReadXmlEntryText(ZipArchiveEntry sourceEntry, Stream sourceEntryStream, PackageNormalizeLimits limits) + { + using var reader = new StreamReader(sourceEntryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: false); + var buffer = new char[4096]; + var builder = new StringBuilder(); + + while (true) + { + var charsRead = reader.Read(buffer, 0, buffer.Length); + if (charsRead == 0) + return builder.ToString(); + + if (builder.Length > limits.MaxXmlTextChars - charsRead) + { + throw new InvalidOperationException( + $"XML ZIP entry {sourceEntry.FullName} exceeds the text limit of {limits.MaxXmlTextChars} characters."); + } + + builder.Append(buffer, 0, charsRead); + } + } + + private static void CopyEntry(Stream sourceEntryStream, Stream destinationEntryStream) + { + var buffer = new byte[81920]; + + while (true) + { + var bytesRead = sourceEntryStream.Read(buffer, 0, buffer.Length); + if (bytesRead == 0) + return; + + destinationEntryStream.Write(buffer, 0, bytesRead); + } + } + + private sealed class PackageNormalizeReadBudget + { + private readonly PackageNormalizeLimits _limits; + private long _totalBytesRead; + + internal PackageNormalizeReadBudget(PackageNormalizeLimits limits) + { + _limits = limits; + } + + internal void AddBytes(ZipArchiveEntry sourceEntry, long entryBytesRead, int bytesRead) + { + if (bytesRead <= 0) + return; + + if (entryBytesRead > _limits.MaxEntryUncompressedBytes - bytesRead) + { + throw new InvalidOperationException( + $"ZIP entry {sourceEntry.FullName} exceeds the per-entry inflated size limit of {_limits.MaxEntryUncompressedBytes} bytes."); + } + + if (_totalBytesRead > _limits.MaxTotalUncompressedBytes - bytesRead) + { + throw new InvalidOperationException( + $"ZIP entry {sourceEntry.FullName} makes actual inflated package size exceed the limit of {_limits.MaxTotalUncompressedBytes} bytes."); + } + + _totalBytesRead += bytesRead; + } + } + + private sealed class BudgetedEntryReadStream : Stream + { + private readonly Stream _inner; + private readonly ZipArchiveEntry _sourceEntry; + private readonly PackageNormalizeReadBudget _readBudget; + private long _entryBytesRead; + + internal BudgetedEntryReadStream(Stream inner, ZipArchiveEntry sourceEntry, PackageNormalizeReadBudget readBudget) + { + _inner = inner; + _sourceEntry = sourceEntry; + _readBudget = readBudget; + } + + public override bool CanRead => _inner.CanRead; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + var bytesRead = _inner.Read(buffer, offset, count); + TrackBytesRead(bytesRead); + return bytesRead; + } + + public override int Read(Span buffer) + { + var bytesRead = _inner.Read(buffer); + TrackBytesRead(bytesRead); + return bytesRead; + } + + public override void Flush() + { + throw new NotSupportedException(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + _inner.Dispose(); + + base.Dispose(disposing); + } + + private void TrackBytesRead(int bytesRead) + { + _readBudget.AddBytes(_sourceEntry, _entryBytesRead, bytesRead); + _entryBytesRead += bytesRead; + } + } + private static bool IsCorePropertiesPart(string entryName) { return entryName.StartsWith("package/services/metadata/core-properties/", StringComparison.Ordinal) @@ -105,3 +362,31 @@ private static string RewriteCorePropertiesReferences(string content, string ori .Replace("/" + originalCorePropertiesPath, "/" + canonical, StringComparison.Ordinal); } } + +internal readonly record struct PackageNormalizeLimits( + int MaxEntryCount, + long MaxEntryUncompressedBytes, + long MaxTotalUncompressedBytes, + int MaxXmlTextChars) +{ + internal static PackageNormalizeLimits Default { get; } = new( + MaxEntryCount: 4096, + MaxEntryUncompressedBytes: 128L * 1024 * 1024, + MaxTotalUncompressedBytes: 512L * 1024 * 1024, + MaxXmlTextChars: 16 * 1024 * 1024); + + internal void Validate() + { + if (MaxEntryCount <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxEntryCount), MaxEntryCount, "ZIP entry count limit must be positive."); + + if (MaxEntryUncompressedBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxEntryUncompressedBytes), MaxEntryUncompressedBytes, "ZIP entry size limit must be positive."); + + if (MaxTotalUncompressedBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxTotalUncompressedBytes), MaxTotalUncompressedBytes, "ZIP total size limit must be positive."); + + if (MaxXmlTextChars <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxXmlTextChars), MaxXmlTextChars, "ZIP XML text limit must be positive."); + } +}