// 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;
}
}
}