diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.Tasks.csproj b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.Tasks.csproj new file mode 100644 index 000000000..878b136be --- /dev/null +++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.Tasks.csproj @@ -0,0 +1,31 @@ + + + + Library + netstandard2.0 + enable + enable + true + true + $(NoWarn);NU5128;NU5129;NU5100 + tasks + true + + + + + + + + + + + + <_DiffToolPublishContent Include="$(OutputPath)PackageDiff\**" /> + + + + + diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.cs b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.cs new file mode 100644 index 000000000..744779cce --- /dev/null +++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/PackageDiff.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Framework; + +public class PackageDiff: Microsoft.Build.Utilities.ToolTask +{ + [Required] + public string BaselinePackage {get; set;} = ""; + + [Required] + public string TestPackage {get; set;} = ""; + + protected override string ToolName { get; } = $"PackageDiff" + (System.Environment.OSVersion.Platform == PlatformID.Unix ? "" : ".exe"); + + protected override MessageImportance StandardOutputLoggingImportance => MessageImportance.High; + protected override bool HandleTaskExecutionErrors() => true; + + protected override string GenerateFullPathToTool() + { + return Path.Combine(Path.GetDirectoryName(typeof(PackageDiff).Assembly.Location)!, "..", "..", "tools", ToolName); + } + + protected override string GenerateCommandLineCommands() + { + return $"\"{BaselinePackage}\" \"{TestPackage}\""; + } +} diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/build/PackageDiff.Tasks.props b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/build/PackageDiff.Tasks.props new file mode 100644 index 000000000..239643148 --- /dev/null +++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff.Tasks/build/PackageDiff.Tasks.props @@ -0,0 +1,9 @@ + + + + <_PackageDiffTasksAssemblyPath>$(MSBuildThisFileDirectory)\..\tasks\netstandard2.0\PackageDiff.Tasks.dll + + + + + diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/PackageDiff.csproj b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/PackageDiff.csproj new file mode 100644 index 000000000..c74b0b783 --- /dev/null +++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/PackageDiff.csproj @@ -0,0 +1,11 @@ + + + + Exe + net9.0 + enable + true + true + + + diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/Program.cs b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/Program.cs new file mode 100644 index 000000000..924c0aba6 --- /dev/null +++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/Program.cs @@ -0,0 +1,160 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Text; +using System.Threading.Tasks; + +public class PackageDiff +{ + public static async Task Main(string[] args) + { + if (args.Length != 2) + { + Console.WriteLine("Usage: PackageDiff "); + return 1; + } + + ZipArchive package1 = await GetZipArchiveAsync(args[0]); + ZipArchive package2 = await GetZipArchiveAsync(args[1]); + var diff = GetDiffs(package1, package2); + if (diff is not "") + { + Console.WriteLine(diff); + return 1; + } + return 0; + } + + public static async Task GetZipArchiveAsync(string arg) + { + if (File.Exists(arg)) + { + return new ZipArchive(File.OpenRead(arg)); + } + else if (Uri.TryCreate(arg, UriKind.RelativeOrAbsolute, out var uri)) + { + var webClient = new HttpClient(); + return new ZipArchive(await webClient.GetStreamAsync(uri)); + } + else + { + throw new ArgumentException($"Invalid path or url to package1: {arg}"); + } + } + + public static string GetDiffs(ZipArchive package1, ZipArchive package2) + { + StringBuilder output = new(); + + if (TryGetDiff(package1.Entries.Select(entry => entry.FullName).ToList(), package2.Entries.Select(entry => entry.FullName).ToList(), out var fileDiffs)) + { + output.AppendLine("File differences:"); + output.AppendLine(string.Join(Environment.NewLine, fileDiffs.Select(d => " " + d))); + output.AppendLine(); + } + + if (TryGetDiff(package1.GetNuspec().Lines(), package2.GetNuspec().Lines(), out var editedDiff)) + { + output.AppendLine("Nuspec differences:"); + output.AppendLine(string.Join(Environment.NewLine, editedDiff.Select(d => " " + d))); + output.AppendLine(); + } + var dlls1 = package1.Entries.Where(entry => entry.FullName.EndsWith(".dll")).ToImmutableDictionary(entry => entry.FullName, entry => entry); + var dlls2 = package2.Entries.Where(entry => entry.FullName.EndsWith(".dll")).ToImmutableDictionary(entry => entry.FullName, entry => entry); + foreach (var kvp in dlls1) + { + var dllPath = kvp.Key; + var dll1 = kvp.Value; + if (dlls2.TryGetValue(dllPath, out ZipArchiveEntry? dll2)) + { + try + { + var version1 = new PEReader(dll1.Open().ReadToEnd().ToImmutableArray()).GetMetadataReader().GetAssemblyDefinition().Version.ToString(); + var version2 = new PEReader(dll2.Open().ReadToEnd().ToImmutableArray()).GetMetadataReader().GetAssemblyDefinition().Version.ToString(); + if (version1 != version2) + { + output.AppendLine($"Assembly {dllPath} has different versions: {version1} and {version2}"); + } + } + catch (InvalidOperationException) + { } + } + } + return output.ToString(); + } + + public static bool TryGetDiff(List originalLines, List modifiedLines, out List formattedDiff) + { + // Edit distance algorithm: https://en.wikipedia.org/wiki/Longest_common_subsequence + + int[,] dp = new int[originalLines.Count + 1, modifiedLines.Count + 1]; + + // Initialize first row and column + for (int i = 0; i <= originalLines.Count; i++) + { + dp[i, 0] = i; + } + for (int j = 0; j <= modifiedLines.Count; j++) + { + dp[0, j] = j; + } + + // Compute edit distance + for (int i = 1; i <= originalLines.Count; i++) + { + for (int j = 1; j <= modifiedLines.Count; j++) + { + if (string.Compare(originalLines[i - 1], modifiedLines[j - 1]) == 0) + { + dp[i, j] = dp[i - 1, j - 1]; + } + else + { + dp[i, j] = 1 + Math.Min(dp[i - 1, j], dp[i, j - 1]); + } + } + } + + // Trace back the edits + int row = originalLines.Count; + int col = modifiedLines.Count; + + formattedDiff = []; + while (row > 0 || col > 0) + { + if (row > 0 && col > 0 && string.Compare(originalLines[row - 1], modifiedLines[col - 1]) == 0) + { + formattedDiff.Add(" " + originalLines[row - 1]); + row--; + col--; + } + else if (col > 0 && (row == 0 || dp[row, col - 1] <= dp[row - 1, col])) + { + formattedDiff.Add("+ " + modifiedLines[col - 1]); + col--; + } + else if (row > 0 && (col == 0 || dp[row, col - 1] > dp[row - 1, col])) + { + formattedDiff.Add("- " + originalLines[row - 1]); + row--; + } + else + { + throw new Exception("Unreachable code"); + } + } + formattedDiff.Reverse(); + return dp[originalLines.Count, modifiedLines.Count] != 0; + } + +} diff --git a/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/ZipExtensions.cs b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/ZipExtensions.cs new file mode 100644 index 000000000..914740b06 --- /dev/null +++ b/src/SourceBuild/content/eng/tools/tasks/PackageDiff/PackageDiff/ZipExtensions.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; + +static class ZipExtensions +{ + public static List Lines(this ZipArchiveEntry entry, Encoding? encoding = null) + { + return entry.ReadToString(encoding).Replace("\r\n", "\n").Split('\n').ToList(); + } + + public static string ReadToString(this ZipArchiveEntry entry, Encoding? encoding = null) + { + Stream stream = entry.Open(); + byte[] buffer = stream.ReadToEnd(); + // Remove UTF-8 BOM if present + int index = 0; + if (buffer[0] == 0xEF && buffer[1] == 0xBB && buffer[2] == 0xBF) + { + index = 3; + } + encoding ??= Encoding.UTF8; + string fileText = encoding.GetString(buffer, index, buffer.Length - index); + return fileText; + } + + public static ZipArchiveEntry GetNuspec(this ZipArchive package) + { + return package.Entries.Where(entry => entry.FullName.EndsWith(".nuspec")).Single(); + } + + public static byte[] ReadToEnd(this Stream stream) + { + int bufferSize = 2048; + byte[] buffer = new byte[bufferSize]; + int offset = 0; + while (true) + { + int bytesRead = stream.Read(buffer, offset, bufferSize - offset); + offset += bytesRead; + if (bytesRead == 0) + { + break; + } + if (offset == bufferSize) + { + Array.Resize(ref buffer, bufferSize * 2); + bufferSize *= 2; + } + } + Array.Resize(ref buffer, offset); + return buffer; + } +}