// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Reflection; using System.Text; using System.Text.Json; namespace ReleaseBuilder.Build; public static partial class Command { /// /// Main compilation of projects /// private static class Verify { /// /// Verify that some files that are expected to be present in the target directory are there /// /// The build directory to verify /// The target to verify for /// An awaitable task public static Task VerifyTargetDirectory(string buildDir, PackageTarget target) { var rootFiles = Directory.EnumerateFiles(buildDir, "*", SearchOption.TopDirectoryOnly) .Where(x => x.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) .Select(x => Path.GetFileName(x)) .ToHashSet(Duplicati.Library.Utility.Utility.ClientFilenameStringComparer); string[] extras = target.OS switch { OSType.Windows => ["Vanara.PInvoke.Kernel32.dll", "Vanara.PInvoke.VssApi.dll", "Duplicati.Library.WindowsModules.dll"], OSType.MacOS => [], OSType.Linux => [], _ => throw new Exception($"Not supported OS: {target.OS}") }; // Random sample of files we expect string[] probeFiles = [ "System.CommandLine.dll", "System.CommandLine.NamingConventionBinder.dll", "AWSSDK.S3.dll", "CoCoL.dll", "Duplicati.Library.Interface.dll", "Google.Apis.Auth.dll", "Google.Apis.Core.dll", "SQLiteHelper.dll", "Microsoft.Data.Sqlite.dll", "Microsoft.IdentityModel.Abstractions.dll", "System.Reactive.dll", .. extras ]; var missing = probeFiles.Where(x => !rootFiles.Contains(x)).ToArray(); if (missing.Length > 0) throw new Exception($"Expected files {string.Join(", ", missing)} for {target.BuildTargetString}, but were not found in build directory {buildDir}"); return Task.CompletedTask; } /// /// Verifies that all expected executables are in the output /// /// The build directory to verify /// The project files to verify /// The target to verify for /// An awaitable task public static Task VerifyExecutables(string buildDir, IEnumerable projectFiles, PackageTarget target) { var expected = projectFiles.Select(x => Path.GetFileNameWithoutExtension(x)) .Select(x => target.OS == OSType.Windows ? $"{x}.exe" : x); var missing = expected.Where(x => !File.Exists(Path.Combine(buildDir, x))).ToArray(); if (missing.Length > 0) throw new Exception($"Expected files {string.Join(", ", missing)} for {target.BuildTargetString}, but were not found in build directory {buildDir}"); return Task.CompletedTask; } /// /// Root entry from the dotnet list output /// /// The version of the output format /// The parameters used to generate the output /// The list of projects found public sealed record RootJson( int Version, string Parameters, IEnumerable Projects ); /// /// A single project /// /// Full path to the csproj file /// The frameworks found public sealed record ProjectJson( string Path, IEnumerable Frameworks ); /// /// Contents of a framework /// /// The framework name /// Directly included packages /// Packages included due to the top-level packages public sealed record FrameworkJson( string Framework, IEnumerable TopLevelPackages, IEnumerable TransitivePackages ); /// /// A top-level package /// /// The package id /// The version requested /// The resolved version public sealed record TopLevelJson( string Id, string RequestedVersion, string ResolvedVersion ); /// /// A transitive package /// /// The package id /// The resolved version public sealed record PackageJson( string Id, string ResolvedVersion ); /// /// Executes the dotnet list command and parses the output /// /// The path to the sln file to analyze /// The parsed output public static async Task AnalyzeProject(string slnpath) { await ProcessHelper.ExecuteWithOutput([ "dotnet", "restore", slnpath ]).ConfigureAwait(false); var output = await ProcessHelper.ExecuteWithOutput([ "dotnet", "list", slnpath, "package", "--include-transitive", "--format", "json" ]).ConfigureAwait(false); var root = JsonSerializer.Deserialize(output, new JsonSerializerOptions(JsonSerializerOptions.Default) { PropertyNameCaseInsensitive = true }) ?? throw new Exception("Failed to parse JSON output from dotnet list"); if (root.Version != 1) throw new Exception($"Unexpected version {root.Version} from dotnet list"); return root; } /// /// Parses a version string into a Version object /// /// The nuget version string /// A .NET version number private static Version ParseVersion(string version) { var v = new Version(version.Split("-")[0]); return new Version(v.Major, v.Minor, v.Build, Math.Max(0, v.Revision)); } /// /// A version that is duplicated in multiple projects /// /// The source project /// The resolved nuget version string /// The resolved parsed version public sealed record DuplicatedVersion( string Project, string Version, Version ParsedVersion ); /// /// Parses the output of the dotnet list command and returns a dictionary of duplicated versions /// /// The parsed output from the dotnet list command /// A dictionary of duplicated versions, where the key is the package id and the value is a list of projects that use that version public static Dictionary> GetDuplicatedVersions(RootJson input) => input.Projects .SelectMany(x => x.Frameworks.Select(y => new { Framework = y, Project = x.Path })) .SelectMany(x => (x.Framework.TopLevelPackages? .Select(y => new { TopLevel = true, y.Id, y.ResolvedVersion, x.Project }) ?? []) .Concat(x.Framework.TransitivePackages?.Select(y => new { TopLevel = false, y.Id, y.ResolvedVersion, x.Project }) ?? []) ) .GroupBy(x => x.Id, x => new DuplicatedVersion(x.Project, x.ResolvedVersion, ParseVersion(x.ResolvedVersion))) .Where(x => x.DistinctBy(y => y.ParsedVersion).Count() > 1) .ToDictionary( x => x.Key, x => x.ToList() ); /// /// Finds the maximum nuget versions of packages /// /// The parsed output from the dotnet list command /// A list of nuget versions for each package public static Dictionary FindMaxNugetVersions(RootJson input) => input.Projects .SelectMany(x => x.Frameworks) .SelectMany(x => (x.TopLevelPackages? .Select(x => new { TopLevel = true, x.Id, x.ResolvedVersion }) ?? []) .Concat(x.TransitivePackages?.Select(x => new { TopLevel = false, x.Id, x.ResolvedVersion }) ?? []) ) .Where(x => !x.TopLevel) .GroupBy(x => x.Id, x => x.ResolvedVersion) .Select(x => new { x.Key, Version = x.MaxBy(ParseVersion) }) .ToDictionary( x => x.Key, x => ParseVersion(x.Version!) ); /// /// List of known wrong versions, where the assembly version is not the same as the nuget version /// private static Dictionary ManuallyFixedVersions = new Dictionary { // Using v4.0 for assembly, but 4.0.6.4 in nuget { "AWSSDK.Core", new Version(4, 0, 0, 0) }, // Using the Framework version, not the package version { "Microsoft.CSharp", new Version(8, 0, 0, 0) }, { "System.Memory", new Version(8, 0, 0, 0) }, { "System.Security.AccessControl", new Version(8, 0, 0, 0) }, { "System.Security.Principal.Windows", new Version(8, 0, 0, 0) }, { "System.Security.Cryptography.Algorithms", new Version(8, 0, 0, 0) }, { "System.Security.Cryptography.Cng", new Version(8, 0, 0, 0) }, // The assembly version also has a revision number, but the nuget version does not. { "SQLitePCLRaw.core", new Version(2, 1, 10, 2445) }, // Using v9.0 for assembly, but 9.0.2 in nuget { "System.IO.Pipelines", new Version(9, 0, 0, 0) }, // Using v9.0 for assembly, but 9.0.6 in nuget { "Microsoft.Win32.SystemEvents", new Version(9, 0, 0, 0) }, { "System.Drawing.Common", new Version(9, 0, 0, 0) }, // Using v6.0.0.1 for assembly, but 6.0.1 in nuget { "System.Memory.Data", new Version(6, 0, 0, 1) } }; /// /// Verifies that the versions of the assemblies in the output folder are the maximum versions /// /// The folder to check /// The parsed output from the dotnet list command /// If true, allows mismatches between the assembly version and the nuget version /// An awaitable task public static Task VerifyDuplicatedVersionsAreMaxVersions(string folder, RootJson input, bool allowAssemblyMismatch) { var duplicatedVersions = GetDuplicatedVersions(input) .Select(x => { if (ManuallyFixedVersions.TryGetValue(x.Key, out var version)) return new KeyValuePair>(x.Key, [new DuplicatedVersion(x.Value.First().Project, x.Value.First().Version, version)]); return new KeyValuePair>(x.Key, x.Value); }); var mismatches = new List<(string Path, Version Expected, Version Actual)>(); foreach (var entry in duplicatedVersions) { var maxVersion = entry.Value.MaxBy(x => x.ParsedVersion) ?? throw new Exception($"Failed to find max version for {entry.Key}"); var filename = Path.Combine(folder, $"{entry.Key}.dll"); if (!File.Exists(filename)) continue; var assemblyVersion = AssemblyName.GetAssemblyName(filename).Version; if (assemblyVersion != null && assemblyVersion != maxVersion.ParsedVersion) mismatches.Add((filename, maxVersion.ParsedVersion, assemblyVersion)); } if (mismatches.Count > 0) { var sb = new StringBuilder(); foreach (var mismatch in mismatches) sb.AppendLine($"File {mismatch.Path} has version {mismatch.Actual} but expected {mismatch.Expected}"); if (!allowAssemblyMismatch) throw new Exception(sb.ToString()); } return Task.CompletedTask; } } }