diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index aef6e23499..1a64ab2e9d 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -116,6 +116,8 @@ bash tools/build-install-sh.sh | Reproducible OPC metadata (#2756) | NuGet's OPC package writer generates a random `package/services/metadata/core-properties/*.psmdcp` part name on each pack run. The normalizer rewrites that part to `package/services/metadata/core-properties/core-properties.psmdcp`, updates the matching content-type and relationship references, and gives ZIP entries stable timestamps. This is the package reproducibility boundary for `.nupkg` and `.snupkg` archives. | | Work bounds (#2892) | 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. | | Unsafe ZIP names (#2894) | Before creating the destination archive, the normalizer rejects absolute paths, Windows drive roots, backslash separators, empty path segments, parent-directory segments, empty normalized names, and destination names that collide after path normalization. Those entries are not preserved into normalized packages. | +| Unsafe ZIP attributes (#3552) | Before copying entries, the normalizer rejects POSIX symlink/device/special-file types and unsafe DOS attributes, then writes normalized entries with scrubbed deterministic external attributes instead of preserving source permission bits. | +| Failure diagnostics (#3458) | The CLI accepts at most 1024 package paths per run, reports bounded package path and ZIP entry diagnostics instead of raw path-heavy exception text, and emits cleanup deletion failures as per-package `warnings` in JSON output. | 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: @@ -2299,6 +2301,8 @@ bash tools/build-install-sh.sh | 再現可能な OPC metadata (#2756) | NuGet の OPC package writer は `package/services/metadata/core-properties/*.psmdcp` part 名を pack ごとにランダム生成します。normalizer はその part を `package/services/metadata/core-properties/core-properties.psmdcp` に書き換え、対応する content-type / relationship 参照も更新し、ZIP entry timestamp を固定します。これが `.nupkg` / `.snupkg` archive の package 再現性境界です。 | | 作業量の上限 (#2892) | 書き換え前に、normalizer は 4096 を超える ZIP entry、128 MiB を超える uncompressed entry、512 MiB を超える合計 uncompressed content、または 16 MiB を超える XML 参照テキストを持つ package を拒否し、細工された package が無制限の normalize 作業を強制できないようにします。 | | unsafe ZIP name (#2894) | destination archive を作る前に、normalizer は absolute path、Windows drive root、backslash separator、空の path segment、parent-directory segment、空に正規化される名前、path 正規化後に衝突する destination 名を拒否します。これらの entry は normalized package に保持されません。 | +| unsafe ZIP attributes (#3552) | entry のコピー前に、normalizer は POSIX symlink / device / special-file type と unsafe DOS 属性を拒否し、source の permission bit を保持せず deterministic に scrub した external attributes で normalized entry を書き込みます。 | +| failure diagnostics (#3458) | CLI は 1 回の実行で受け付ける package path を最大 1024 件に制限し、raw な path-heavy exception text ではなく bounded な package path / ZIP entry diagnostics を報告し、cleanup 削除失敗を JSON 出力の package ごとの `warnings` として出します。 | 依存を意図的に更新する(あるいは直接 `PackageReference` を追加する)場合は、ローカルで lock ファイルを再生成し、同じ変更でコミットしてください: diff --git a/changelog.d/unreleased/3458.fixed.md b/changelog.d/unreleased/3458.fixed.md new file mode 100644 index 0000000000..fa3abb1176 --- /dev/null +++ b/changelog.d/unreleased/3458.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3458 +affected: + - DEVELOPER_GUIDE.md + - tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs + - tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +--- + +## English + +- **PackageNormalize now reports bounded friendly failures and cleanup warnings (#3458)** — package normalization errors now avoid raw path-heavy exception text, cap package arguments per run, bound ZIP entry diagnostics, and include structured cleanup warnings in JSON output. + +## 日本語 + +- **PackageNormalize が bounded で friendly な失敗内容と cleanup warning を報告するようになりました (#3458)** — package normalization error は raw な path-heavy exception text を避け、1 回の実行で受け付ける package 引数数を制限し、ZIP entry diagnostics を bounded にし、JSON 出力に構造化された cleanup warning を含めます。 diff --git a/changelog.d/unreleased/3552.fixed.md b/changelog.d/unreleased/3552.fixed.md new file mode 100644 index 0000000000..eff558b7e8 --- /dev/null +++ b/changelog.d/unreleased/3552.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3552 +affected: + - DEVELOPER_GUIDE.md + - tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs + - tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +--- + +## English + +- **PackageNormalize no longer preserves unsafe ZIP external attributes (#3552)** — normalized packages now scrub entry external attributes to a deterministic safe value after rejecting POSIX special-file types and unsafe DOS attributes. + +## 日本語 + +- **PackageNormalize が安全でない ZIP external attributes を保持しないようになりました (#3552)** — 正規化後の package は、POSIX special-file type や unsafe DOS 属性を拒否したうえで、entry external attributes を deterministic な安全値へ scrub します。 diff --git a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs index bcfc7efc2f..1e3395bf56 100644 --- a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +++ b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs @@ -294,6 +294,110 @@ public void PackageNormalizeCli_JsonContinueOnErrorReportsAggregateSummary() } } + [Fact] + public void PackageNormalizeCli_RejectsTooManyPackageArguments() + { + var args = Enumerable + .Range(0, PackageNormalizeOptions.MaxPackageArgumentCount + 1) + .Select(index => $"package-{index}.nupkg") + .ToArray(); + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => PackageNormalizeCli.Run(args)); + + Assert.Equal(1, exitCode); + Assert.Empty(stdout); + Assert.Contains($"at most {PackageNormalizeOptions.MaxPackageArgumentCount} package paths", stderr); + } + + [Fact] + public void PackageNormalizeCli_JsonReportsBoundedFriendlyFailure() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizeCli_JsonReportsBoundedFriendlyFailure)); + try + { + var missingPackagePath = Path.Combine(projectRoot, "missing.nupkg"); + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + PackageNormalizeCli.Run(["--json", missingPackagePath])); + + Assert.Equal(1, exitCode); + Assert.Empty(stderr); + using var doc = JsonDocument.Parse(stdout); + var package = doc.RootElement.GetProperty("packages").EnumerateArray().Single(); + var error = package.GetProperty("error").GetString(); + Assert.Contains("missing.nupkg", error); + Assert.DoesNotContain(projectRoot, error); + Assert.True(error!.Length <= 512); + Assert.Empty(package.GetProperty("warnings").EnumerateArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizeCli_JsonBoundsZipEntryDiagnostics() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizeCli_JsonBoundsZipEntryDiagnostics)); + try + { + var packagePath = Path.Combine(projectRoot, "unsafe-entry.nupkg"); + var longEntryName = new string('a', 260) + "\\payload.txt"; + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + (longEntryName, "payload")); + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + PackageNormalizeCli.Run(["--json", packagePath])); + + Assert.Equal(1, exitCode); + Assert.Empty(stderr); + using var doc = JsonDocument.Parse(stdout); + var error = doc.RootElement.GetProperty("packages").EnumerateArray().Single().GetProperty("error").GetString(); + Assert.Contains("aaa", error); + Assert.Contains("...", error); + Assert.DoesNotContain(longEntryName, error); + Assert.True(error!.Length <= 512); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_ReportsCleanupWarningsWhenTempDeleteFails() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_ReportsCleanupWarningsWhenTempDeleteFails)); + try + { + var packagePath = Path.Combine(projectRoot, "cleanup-warning.nupkg"); + CreateMinimalNuGetPackage(packagePath, "random.psmdcp"); + var limits = PackageNormalizeLimits.Default with { MaxXmlTextChars = 5 }; + var warnings = new List(); + + var exception = Assert.Throws(() => + PackageCorePropertiesNormalizer.NormalizePackage( + packagePath, + limits, + warnings, + _ => throw new IOException("delete failed at /private/path"))); + + Assert.Contains("[Content_Types].xml", exception.Message); + var warning = Assert.Single(warnings); + Assert.Contains("Could not delete temporary normalized package", warning); + Assert.Contains("cleanup-warning.nupkg.normalize-tmp", warning); + Assert.DoesNotContain(projectRoot, warning); + Assert.DoesNotContain("/private/path", warning); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void PackageNormalizer_RejectsPackageThatExceedsEntryCountLimit() { @@ -452,7 +556,76 @@ public void PackageNormalizer_RejectsDestinationNamesThatNormalizeToDuplicates() 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.Contains("duplicate destination name 'docs/readme.txt'", exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_ScrubsSafeExternalAttributes() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_ScrubsSafeExternalAttributes)); + try + { + var packagePath = Path.Combine(projectRoot, "external-attributes.nupkg"); + CreatePackageWithAttributedEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", "", UnixRegularFileAttributes(493)), + ("payload.bin", "payload", 0x20)); + + PackageCorePropertiesNormalizer.NormalizePackage(packagePath); + + using var archive = ZipFile.OpenRead(packagePath); + Assert.All(archive.Entries, entry => Assert.Equal(0, entry.ExternalAttributes)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_RejectsPosixSymlinkExternalAttributes() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsPosixSymlinkExternalAttributes)); + try + { + var packagePath = Path.Combine(projectRoot, "symlink-attributes.nupkg"); + CreatePackageWithAttributedEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", "", 0), + ("payload.bin", "payload", UnixSymlinkAttributes())); + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath)); + Assert.Contains("payload.bin", exception.Message); + Assert.Contains("unsafe POSIX file type symlink", exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_RejectsUnsafeDosExternalAttributes() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsUnsafeDosExternalAttributes)); + try + { + var packagePath = Path.Combine(projectRoot, "dos-attributes.nupkg"); + CreatePackageWithAttributedEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", "", 0), + ("payload.bin", "payload", 0x04)); + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath)); + Assert.Contains("payload.bin", exception.Message); + Assert.Contains("unsafe DOS attributes 0x04", exception.Message); Assert.False(File.Exists(packagePath + ".normalize-tmp")); } finally @@ -559,14 +732,34 @@ private static void CreatePackageWithEntries(string packagePath, params (string WriteZipEntry(archive, entry.EntryName, entry.Content); } - private static void WriteZipEntry(ZipArchive archive, string entryName, string content) + private static void CreatePackageWithAttributedEntries(string packagePath, params (string EntryName, string Content, int ExternalAttributes)[] entries) + { + using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); + foreach (var entry in entries) + WriteZipEntry(archive, entry.EntryName, entry.Content, entry.ExternalAttributes); + } + + private static void WriteZipEntry(ZipArchive archive, string entryName, string content, int? externalAttributes = null) { var entry = archive.CreateEntry(entryName); + if (externalAttributes.HasValue) + entry.ExternalAttributes = externalAttributes.Value; + using var stream = entry.Open(); using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); writer.Write(content.Replace("\r\n", "\n", StringComparison.Ordinal)); } + private static int UnixRegularFileAttributes(int permissions) + { + return unchecked((int)((0x8000u | (uint)permissions) << 16)); + } + + private static int UnixSymlinkAttributes() + { + return unchecked((int)((0xA000u | 511u) << 16)); + } + private static string ReadZipEntryText(ZipArchive archive, string entryName) { var entry = archive.GetEntry(entryName) ?? throw new InvalidOperationException($"Missing ZIP entry: {entryName}"); diff --git a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs index 4257ec2042..55b77abe0e 100644 --- a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs +++ b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs @@ -27,6 +27,7 @@ public static int Run(string[] args) foreach (var packagePath in options.PackagePaths) { summary.Inspected++; + var warnings = new List(); try { if (options.DryRun) @@ -35,33 +36,40 @@ public static int Run(string[] args) if (inspection.NeedsNormalization) { summary.Skipped++; - results.Add(new PackageNormalizePackageResult(packagePath, "would_normalize", null)); + results.Add(new PackageNormalizePackageResult(packagePath, "would_normalize", null, warnings)); if (!options.Json) Console.WriteLine($"Would normalize {packagePath}"); } else { summary.Unchanged++; - results.Add(new PackageNormalizePackageResult(packagePath, "unchanged", null)); + results.Add(new PackageNormalizePackageResult(packagePath, "unchanged", null, warnings)); if (!options.Json) Console.WriteLine($"Unchanged {packagePath}"); } } else { - PackageCorePropertiesNormalizer.NormalizePackage(packagePath); + PackageCorePropertiesNormalizer.NormalizePackage(packagePath, PackageNormalizeLimits.Default, warnings); summary.Normalized++; - results.Add(new PackageNormalizePackageResult(packagePath, "normalized", null)); + results.Add(new PackageNormalizePackageResult(packagePath, "normalized", null, warnings)); if (!options.Json) + { Console.WriteLine($"Normalized {packagePath}"); + WriteWarnings(warnings); + } } } catch (Exception ex) { + var error = PackageNormalizeDiagnostics.FormatException(packagePath, ex); summary.Failed++; - results.Add(new PackageNormalizePackageResult(packagePath, "failed", ex.Message)); + results.Add(new PackageNormalizePackageResult(packagePath, "failed", error, warnings)); if (!options.Json) - Console.Error.WriteLine($"Failed {packagePath}: {ex.Message}"); + { + Console.Error.WriteLine($"Failed {PackageNormalizeDiagnostics.FormatPath(packagePath)}: {error}"); + WriteWarnings(warnings); + } if (!options.ContinueOnError) break; @@ -95,10 +103,18 @@ private static void WriteUsage() { Console.Error.WriteLine("Usage: dotnet run --project tools/CodeIndex.PackageNormalize -- [--dry-run|--check] [--summary] [--json] [--continue-on-error] [...]"); } + + private static void WriteWarnings(IReadOnlyList warnings) + { + foreach (var warning in warnings) + Console.Error.WriteLine($"Warning: {warning}"); + } } internal sealed class PackageNormalizeOptions { + internal const int MaxPackageArgumentCount = 1024; + private PackageNormalizeOptions(bool dryRun, bool summary, bool json, bool continueOnError, IReadOnlyList packagePaths) { DryRun = dryRun; @@ -148,6 +164,13 @@ internal static bool TryParse(string[] args, out PackageNormalizeOptions options } packagePaths.Add(arg); + if (packagePaths.Count > MaxPackageArgumentCount) + { + options = null!; + error = $"at most {MaxPackageArgumentCount} package paths are supported per run."; + return false; + } + break; } } @@ -168,7 +191,8 @@ internal static bool TryParse(string[] args, out PackageNormalizeOptions options internal sealed record PackageNormalizePackageResult( [property: System.Text.Json.Serialization.JsonPropertyName("path")] string Path, [property: System.Text.Json.Serialization.JsonPropertyName("status")] string Status, - [property: System.Text.Json.Serialization.JsonPropertyName("error")] string? Error); + [property: System.Text.Json.Serialization.JsonPropertyName("error")] string? Error, + [property: System.Text.Json.Serialization.JsonPropertyName("warnings")] IReadOnlyList Warnings); internal sealed record PackageNormalizeJsonResult( [property: System.Text.Json.Serialization.JsonPropertyName("dry_run")] bool DryRun, @@ -183,10 +207,104 @@ internal sealed record PackageNormalizeJsonResult( [System.Text.Json.Serialization.JsonSerializable(typeof(PackageNormalizeJsonResult))] internal sealed partial class PackageNormalizeJsonContext : System.Text.Json.Serialization.JsonSerializerContext; +internal static class PackageNormalizeDiagnostics +{ + private const int MaxDiagnosticValueChars = 160; + private const int MaxDiagnosticMessageChars = 512; + + internal static string FormatException(string packagePath, Exception exception) + { + return exception switch + { + InvalidDataException => $"Package {FormatPath(packagePath)} is not a readable ZIP archive.", + IOException => $"Could not read or rewrite package {FormatPath(packagePath)}.", + UnauthorizedAccessException => $"Could not access package {FormatPath(packagePath)}.", + ArgumentException => FormatMessage(exception.Message), + InvalidOperationException => FormatMessage(exception.Message), + _ => $"Unexpected package normalization failure for {FormatPath(packagePath)}: {exception.GetType().Name}.", + }; + } + + internal static string FormatPath(string path) + { + string display; + try + { + display = Path.GetFileName(path); + } + catch (ArgumentException) + { + display = path; + } + + if (string.IsNullOrEmpty(display)) + display = path; + + return Quote(FormatValue(display, MaxDiagnosticValueChars)); + } + + internal static string FormatEntryName(string entryName) + { + return Quote(FormatValue(entryName, MaxDiagnosticValueChars)); + } + + internal static string FormatMessage(string message) + { + return FormatValue(message, MaxDiagnosticMessageChars); + } + + internal static string FormatCleanupWarning(string tempPath, Exception exception) + { + return $"Could not delete temporary normalized package {FormatPath(tempPath)}: {exception.GetType().Name}."; + } + + private static string Quote(string value) + { + return $"'{value}'"; + } + + private static string FormatValue(string value, int maxChars) + { + var builder = new StringBuilder(Math.Min(value.Length, maxChars)); + foreach (var ch in value) + { + if (builder.Length >= maxChars) + break; + + builder.Append(IsSafeDiagnosticChar(ch) ? ch : '?'); + } + + if (value.Length > maxChars && builder.Length >= 3) + { + builder.Length -= 3; + builder.Append("..."); + } + + return builder.ToString(); + } + + private static bool IsSafeDiagnosticChar(char ch) + { + return ch >= ' ' && ch != '\u007F'; + } +} + public static class PackageCorePropertiesNormalizer { public const string CanonicalCorePropertiesPath = "package/services/metadata/core-properties/core-properties.psmdcp"; + private const int SafeExternalAttributes = 0; + private const int DosAttributeMask = 0xFF; + private const int DosArchiveAttribute = 0x20; + private const int UnixFileTypeMask = 0xF000; + private const int UnixRegularFileType = 0x8000; + private const int UnixFifoFileType = 0x1000; + private const int UnixCharacterDeviceFileType = 0x2000; + private const int UnixDirectoryFileType = 0x4000; + private const int UnixBlockDeviceFileType = 0x6000; + private const int UnixSymlinkFileType = 0xA000; + private const int UnixSocketFileType = 0xC000; + private static readonly DateTimeOffset StableZipTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); public static void NormalizePackage(string packagePath) @@ -195,8 +313,23 @@ public static void NormalizePackage(string packagePath) } internal static void NormalizePackage(string packagePath, PackageNormalizeLimits limits) + { + NormalizePackage(packagePath, limits, warnings: null); + } + + internal static void NormalizePackage(string packagePath, PackageNormalizeLimits limits, IList? warnings) + { + NormalizePackage(packagePath, limits, warnings, File.Delete); + } + + internal static void NormalizePackage( + string packagePath, + PackageNormalizeLimits limits, + IList? warnings, + Action deleteFile) { ArgumentException.ThrowIfNullOrWhiteSpace(packagePath); + ArgumentNullException.ThrowIfNull(deleteFile); limits.Validate(); var fullPath = Path.GetFullPath(packagePath); @@ -226,11 +359,11 @@ internal static void NormalizePackage(string packagePath, PackageNormalizeLimits : sourceEntry.FullName; if (!usedNames.Add(destinationName)) - throw new InvalidOperationException($"Duplicate ZIP entry after normalization: {destinationName}"); + throw new InvalidOperationException($"Duplicate ZIP entry after normalization: {PackageNormalizeDiagnostics.FormatEntryName(destinationName)}"); var destinationEntry = destinationArchive.CreateEntry(destinationName, CompressionLevel.Optimal); destinationEntry.LastWriteTime = StableZipTimestamp; - destinationEntry.ExternalAttributes = sourceEntry.ExternalAttributes; + destinationEntry.ExternalAttributes = SafeExternalAttributes; using var rawSourceEntryStream = sourceEntry.Open(); using var sourceEntryStream = new BudgetedEntryReadStream(rawSourceEntryStream, sourceEntry, readBudget); @@ -254,7 +387,7 @@ internal static void NormalizePackage(string packagePath, PackageNormalizeLimits finally { if (!completed) - TryDeleteFile(tempPath); + TryDeleteFile(tempPath, warnings, deleteFile); } } @@ -282,7 +415,7 @@ internal static PackageNormalizeInspection InspectPackage(string packagePath, Pa 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}."); + throw new InvalidOperationException($"Package {PackageNormalizeDiagnostics.FormatPath(packagePath)} has {sourceArchive.Entries.Count} ZIP entries, which exceeds the limit of {limits.MaxEntryCount}."); string? originalCorePropertiesPath = null; var corePropertiesEntryCount = 0; @@ -290,12 +423,13 @@ private static string ValidateSourceArchive(ZipArchive sourceArchive, string pac foreach (var sourceEntry in sourceArchive.Entries) { + ValidateExternalAttributes(sourceEntry); 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."); + $"ZIP entry {PackageNormalizeDiagnostics.FormatEntryName(sourceEntry.FullName)} makes package uncompressed size exceed the limit of {limits.MaxTotalUncompressedBytes} bytes."); } totalUncompressedBytes += sourceEntry.Length; @@ -308,7 +442,7 @@ private static string ValidateSourceArchive(ZipArchive sourceArchive, string pac } if (corePropertiesEntryCount != 1) - throw new InvalidOperationException($"Expected exactly one NuGet core-properties part in {packagePath}, found {corePropertiesEntryCount}."); + throw new InvalidOperationException($"Expected exactly one NuGet core-properties part in {PackageNormalizeDiagnostics.FormatPath(packagePath)}, found {corePropertiesEntryCount}."); return originalCorePropertiesPath!; } @@ -329,7 +463,7 @@ private static void ValidateEntryNamesBeforeRewrite(ZipArchive sourceArchive, st if (!normalizedDestinationNames.Add(normalizedDestinationName)) { throw new InvalidOperationException( - $"ZIP entry {destinationName} normalizes to duplicate destination name {normalizedDestinationName}."); + $"ZIP entry {PackageNormalizeDiagnostics.FormatEntryName(destinationName)} normalizes to duplicate destination name {PackageNormalizeDiagnostics.FormatEntryName(normalizedDestinationName)}."); } } } @@ -340,23 +474,23 @@ private static string ValidateZipEntryName(string entryName, string role) 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."); + throw new InvalidOperationException($"ZIP {role} entry {PackageNormalizeDiagnostics.FormatEntryName(entryName)} must use '/' separators, not backslashes."); if (entryName.Contains('\0')) - throw new InvalidOperationException($"ZIP {role} entry {entryName} must not contain NUL characters."); + throw new InvalidOperationException($"ZIP {role} entry {PackageNormalizeDiagnostics.FormatEntryName(entryName)} must not contain NUL characters."); if (entryName[0] == '/' || StartsWithWindowsDrivePrefix(entryName)) - throw new InvalidOperationException($"ZIP {role} entry {entryName} must be a relative path."); + throw new InvalidOperationException($"ZIP {role} entry {PackageNormalizeDiagnostics.FormatEntryName(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."); + throw new InvalidOperationException($"ZIP {role} entry {PackageNormalizeDiagnostics.FormatEntryName(entryName)} must not contain empty path segments."); if (segment == "..") - throw new InvalidOperationException($"ZIP {role} entry {entryName} must not contain parent-directory segments."); + throw new InvalidOperationException($"ZIP {role} entry {PackageNormalizeDiagnostics.FormatEntryName(entryName)} must not contain parent-directory segments."); if (segment == ".") continue; @@ -365,11 +499,11 @@ private static string ValidateZipEntryName(string entryName, string role) } if (normalizedSegments.Count == 0) - throw new InvalidOperationException($"ZIP {role} entry {entryName} must not normalize to an empty path."); + throw new InvalidOperationException($"ZIP {role} entry {PackageNormalizeDiagnostics.FormatEntryName(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."); + throw new InvalidOperationException($"ZIP {role} entry {PackageNormalizeDiagnostics.FormatEntryName(entryName)} must be a relative path."); return normalizedName; } @@ -386,10 +520,45 @@ private static void ValidateEntrySize(ZipArchiveEntry sourceEntry, PackageNormal 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."); + $"ZIP entry {PackageNormalizeDiagnostics.FormatEntryName(sourceEntry.FullName)} is {sourceEntry.Length} bytes uncompressed, which exceeds the per-entry limit of {limits.MaxEntryUncompressedBytes} bytes."); } } + private static void ValidateExternalAttributes(ZipArchiveEntry sourceEntry) + { + var externalAttributes = sourceEntry.ExternalAttributes; + var unixMode = (externalAttributes >> 16) & 0xFFFF; + var unixFileType = unixMode & UnixFileTypeMask; + + if (unixFileType != 0 && unixFileType != UnixRegularFileType) + { + throw new InvalidOperationException( + $"ZIP entry {PackageNormalizeDiagnostics.FormatEntryName(sourceEntry.FullName)} uses unsafe POSIX file type {DescribeUnixFileType(unixFileType)} in external attributes."); + } + + var dosAttributes = externalAttributes & DosAttributeMask; + var unsafeDosAttributes = dosAttributes & ~DosArchiveAttribute; + if (unsafeDosAttributes != 0) + { + throw new InvalidOperationException( + $"ZIP entry {PackageNormalizeDiagnostics.FormatEntryName(sourceEntry.FullName)} uses unsafe DOS attributes 0x{unsafeDosAttributes:X2}."); + } + } + + private static string DescribeUnixFileType(int fileType) + { + return fileType switch + { + UnixFifoFileType => "fifo", + UnixCharacterDeviceFileType => "character-device", + UnixDirectoryFileType => "directory", + UnixBlockDeviceFileType => "block-device", + UnixSymlinkFileType => "symlink", + UnixSocketFileType => "socket", + _ => $"0x{fileType:X4}", + }; + } + private static string ReadXmlEntryText(ZipArchiveEntry sourceEntry, Stream sourceEntryStream, PackageNormalizeLimits limits) { using var reader = new StreamReader(sourceEntryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: false); @@ -405,7 +574,7 @@ private static string ReadXmlEntryText(ZipArchiveEntry sourceEntry, Stream sourc if (builder.Length > limits.MaxXmlTextChars - charsRead) { throw new InvalidOperationException( - $"XML ZIP entry {sourceEntry.FullName} exceeds the text limit of {limits.MaxXmlTextChars} characters."); + $"XML ZIP entry {PackageNormalizeDiagnostics.FormatEntryName(sourceEntry.FullName)} exceeds the text limit of {limits.MaxXmlTextChars} characters."); } builder.Append(buffer, 0, charsRead); @@ -426,15 +595,16 @@ private static void CopyEntry(Stream sourceEntryStream, Stream destinationEntryS } } - private static void TryDeleteFile(string path) + private static void TryDeleteFile(string path, IList? warnings, Action deleteFile) { try { if (File.Exists(path)) - File.Delete(path); + deleteFile(path); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { + warnings?.Add(PackageNormalizeDiagnostics.FormatCleanupWarning(path, ex)); } } @@ -456,13 +626,13 @@ internal void AddBytes(ZipArchiveEntry sourceEntry, long entryBytesRead, int byt if (entryBytesRead > _limits.MaxEntryUncompressedBytes - bytesRead) { throw new InvalidOperationException( - $"ZIP entry {sourceEntry.FullName} exceeds the per-entry inflated size limit of {_limits.MaxEntryUncompressedBytes} bytes."); + $"ZIP entry {PackageNormalizeDiagnostics.FormatEntryName(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."); + $"ZIP entry {PackageNormalizeDiagnostics.FormatEntryName(sourceEntry.FullName)} makes actual inflated package size exceed the limit of {_limits.MaxTotalUncompressedBytes} bytes."); } _totalBytesRead += bytesRead;