File: GetPackagesToPrune.cs
Web Access
Project: ..\..\..\src\Tasks\Microsoft.NET.Build.Tasks\Microsoft.NET.Build.Tasks.csproj (Microsoft.NET.Build.Tasks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#nullable disable
 
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Microsoft.ComponentDetection.Detectors.NuGet;
using Microsoft.NET.Build.Tasks.ConflictResolution;
using NuGet.Frameworks;
using NuGet.Versioning;
 
namespace Microsoft.NET.Build.Tasks
{
    public class GetPackagesToPrune : TaskBase
    {
        [Required]
        public string TargetFrameworkIdentifier { get; set; }
 
        [Required]
        public string TargetFrameworkVersion { get; set; }
 
        [Required]
        public ITaskItem[] FrameworkReferences { get; set; }
 
        [Required]
        public ITaskItem[] TargetingPacks { get; set; }
 
        [Required]
        public string[] TargetingPackRoots { get; set; }
 
        [Required]
        public string PrunePackageDataRoot { get; set; }
 
        [Required]
        public bool AllowMissingPrunePackageData { get; set; }
 
        [Output]
        public ITaskItem[] PackagesToPrune { get; set; }
 
        class CacheKey
        {
            public string TargetFrameworkIdentifier { get; set; }
            public string TargetFrameworkVersion { get; set; }
            public HashSet<string> FrameworkReferences { get; set; }
 
            public override bool Equals(object obj) => obj is CacheKey key &&
                TargetFrameworkIdentifier == key.TargetFrameworkIdentifier &&
                TargetFrameworkVersion == key.TargetFrameworkVersion &&
                FrameworkReferences.SetEquals(key.FrameworkReferences);
            public override int GetHashCode()
            {
#if NET
                var hashCode = new HashCode();
                hashCode.Add(TargetFrameworkIdentifier);
                hashCode.Add(TargetFrameworkVersion);
                foreach (var frameworkReference in FrameworkReferences)
                {
                    hashCode.Add(frameworkReference);
                }
                return hashCode.ToHashCode();
#else
                int hashCode = 1436330440;
                hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(TargetFrameworkIdentifier);
                hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(TargetFrameworkVersion);
 
                foreach (var frameworkReference in FrameworkReferences)
                {
                    hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(frameworkReference);
                }
                return hashCode;
#endif
            }
        }
 
 
        protected override void ExecuteCore()
        {
 
            //  Filter out transitive framework references.  Normally they wouldn't be passed to this task, but in Visual Studio design-time builds
            //  the ResolvePackageAssets and AddTransitiveFrameworkReferences targets may have already run.  Filtering these references out should
            //  avoid a bug similar to https://github.com/dotnet/sdk/issues/14641
            var filteredFrameworkReferences = FrameworkReferences.Where(
                i => i.GetMetadata("IsTransitiveFrameworkReference") is string transitiveVal && !transitiveVal.Equals("true", StringComparison.OrdinalIgnoreCase)).ToList();
 
            //  Map framework references to runtime frameworks, so we can correctly handle framework references to profiles.
            //  For example, for a framework reference of Microsoft.WindowsDesktop.App.WindowsForms, we map it to the
            //  runtime framework of Microsoft.WindowsDesktop.App, which is what the pruned packages are defined in terms of
            List<string> runtimeFrameworks = new List<string>();
 
            foreach (var frameworkReference in filteredFrameworkReferences)
            {
                //  Number of framework references is generally low enough that it's not worth putting the targeting packs into a hash set
                var targetingPack = TargetingPacks.FirstOrDefault(tp => tp.ItemSpec.Equals(frameworkReference.ItemSpec, StringComparison.OrdinalIgnoreCase));
                if (targetingPack != null)
                {
                    runtimeFrameworks.Add(targetingPack.GetMetadata("RuntimeFrameworkName"));
                }
            }
 
            CacheKey key = new()
            {
                TargetFrameworkIdentifier = TargetFrameworkIdentifier,
                TargetFrameworkVersion = TargetFrameworkVersion,
                FrameworkReferences = runtimeFrameworks.ToHashSet()
            };
 
            //  Cache framework package values per build
            var existingResult = BuildEngine4.GetRegisteredTaskObject(key, RegisteredTaskObjectLifetime.Build);
            if (existingResult != null)
            {
                PackagesToPrune = (TaskItem[])existingResult;
                return;
            }
 
            PackagesToPrune = LoadPackagesToPrune(key, TargetingPackRoots, PrunePackageDataRoot, Log, AllowMissingPrunePackageData);
 
            BuildEngine4.RegisterTaskObject(key, PackagesToPrune, RegisteredTaskObjectLifetime.Build, true);
        }
 
        static TaskItem[] LoadPackagesToPrune(CacheKey key, string[] targetingPackRoots, string prunePackageDataRoot, Logger log, bool allowMissingPrunePackageData)
        {
            Dictionary<string, NuGetVersion> packagesToPrune = new();
 
            var targetFrameworkVersion = Version.Parse(key.TargetFrameworkVersion);
 
            if (key.FrameworkReferences.Count == 0 && key.TargetFrameworkIdentifier.Equals(".NETCoreApp") && targetFrameworkVersion.Major >= 3)
            {
                //  For .NET Core projects (3.0 and higher), don't prune any packages if there are no framework references
                return Array.Empty<TaskItem>();
            }
 
            // Use hard-coded / generated "framework package data" for .NET 9 and lower, .NET Framework, and .NET Standard
            // Use bundled "prune package data" for .NET 10 and higher.  During the redist build, this comes from targeting packs and is laid out in the PrunePackageData folder.
            bool useFrameworkPackageData = !key.TargetFrameworkIdentifier.Equals(".NETCoreApp") || targetFrameworkVersion.Major < 10;
 
            //  Call DefaultIfEmpty() so that target frameworks without framework references will load data
            foreach (var frameworkReference in key.FrameworkReferences.DefaultIfEmpty(""))
            {
                //  Filter out framework references we don't expect to have prune data for, such as Microsoft.Windows.SDK.NET.Ref
                if (!frameworkReference.Equals(string.Empty, StringComparison.OrdinalIgnoreCase) &&
                    !frameworkReference.Equals("Microsoft.NETCore.App", StringComparison.OrdinalIgnoreCase) &&
                    !frameworkReference.Equals("Microsoft.AspNetCore.App", StringComparison.OrdinalIgnoreCase) &&
                    !frameworkReference.Equals("Microsoft.WindowsDesktop.App", StringComparison.OrdinalIgnoreCase))
                {
                    continue;
                }
                log.LogMessage(MessageImportance.Low, $"Loading packages to prune for {key.TargetFrameworkIdentifier} {key.TargetFrameworkVersion} {frameworkReference}");
 
                Dictionary<string, NuGetVersion> packagesForFrameworkReference;
                if (useFrameworkPackageData)
                {
                    packagesForFrameworkReference = LoadPackagesToPruneFromFrameworkPackages(key.TargetFrameworkIdentifier, key.TargetFrameworkVersion, frameworkReference);
                    if (packagesForFrameworkReference != null)
                    {
                        log.LogMessage("Loaded prune package data from framework packages");
                    }
                    else
                    {
                        log.LogMessage("Failed to load prune package data from framework packages");
                    }
                }
                else
                {
                    log.LogMessage("Loading prune package data from PrunePackageData folder");
                    packagesForFrameworkReference = LoadPackagesToPruneFromPrunePackageData(key.TargetFrameworkIdentifier, key.TargetFrameworkVersion, frameworkReference, prunePackageDataRoot);
 
                    //  For the version of the runtime that matches the current SDK version, we don't include the prune package data in the PrunePackageData folder.  Rather,
                    //  we can load it from the targeting packs that are packaged with the SDK.
                    if (packagesForFrameworkReference == null)
                    {
                        log.LogMessage("Failed to load prune package data from PrunePackageData folder, loading from targeting packs instead");
                        packagesForFrameworkReference = LoadPackagesToPruneFromTargetingPack(log, key.TargetFrameworkIdentifier, key.TargetFrameworkVersion, frameworkReference, targetingPackRoots);
                    }
 
                    //  Fall back to framework packages data for older framework for WindowsDesktop if necessary
                    //  https://github.com/dotnet/windowsdesktop/issues/4904
                    if (packagesForFrameworkReference == null && frameworkReference.Equals("Microsoft.WindowsDesktop.App", StringComparison.OrdinalIgnoreCase))
                    {
                        log.LogMessage("Failed to load prune package data for WindowsDesktop from targeting packs, loading from framework packages instead");
                        packagesForFrameworkReference = LoadPackagesToPruneFromFrameworkPackages(key.TargetFrameworkIdentifier, key.TargetFrameworkVersion, frameworkReference,
                            acceptNearestMatch: true);
                    }
                }
 
                if (packagesForFrameworkReference == null)
                {
                    //  We didn't find the data for packages to prune.  This indicates that there's a bug in the SDK construction, so fail hard here so that we fix that
                    //  (rather than a warning that might be missed).
                    //  Since this indicates an error in the SDK build, the message probably doesn't need to be localized.
 
                    if (allowMissingPrunePackageData)
                    {
                        log.LogMessage($"Prune package data not found for {key.TargetFrameworkIdentifier} {key.TargetFrameworkVersion} {frameworkReference}");
                    }
                    else
                    {
                        log.LogError(Strings.PrunePackageDataNotFound, key.TargetFrameworkIdentifier, key.TargetFrameworkVersion, frameworkReference);
                    }
                }
                else
                {
                    AddPackagesToPrune(packagesToPrune, packagesForFrameworkReference.Select(kvp => (kvp.Key, kvp.Value)), log);
                }
            }
 
 
 
            return packagesToPrune.Select(p =>
            {
                var item = new TaskItem(p.Key);
 
                string version;
                if (key.TargetFrameworkIdentifier.Equals(".NETCoreApp", StringComparison.OrdinalIgnoreCase) && !p.Value.IsPrerelease)
                {
                    //  If a given version of a package is included in a framework, assume that any patches
                    //  to that package will be included in patches to the framework, and thus should be pruned.
                    //  See https://github.com/dotnet/sdk/issues/44566
                    //  To do this, we set the patch version for the package to be pruned to 32767, which should be
                    //  higher than any actual patch version.
                    var maxPatch = new NuGetVersion(p.Value.Major, p.Value.Minor, 32767);
                    version = maxPatch.ToString();
                }
                else
                {
                    version = p.Value.ToString();
                }
 
                item.SetMetadata("Version", version.ToString());
                return item;
            }).ToArray();
        }
 
        static Dictionary<string, NuGetVersion> LoadPackagesToPruneFromFrameworkPackages(string targetFrameworkIdentifier, string targetFrameworkVersion, string frameworkReference, bool acceptNearestMatch = false)
        {
            var nugetFramework = new NuGetFramework(targetFrameworkIdentifier, Version.Parse(targetFrameworkVersion));
 
            //  FrameworkPackages just has data for .NET Framework 4.6.1, so turn on fallback for anything greater than that so it will resolve to the .NET Framework 4.6.1 data
            if (!acceptNearestMatch && nugetFramework.IsDesktop() && nugetFramework.Version > new Version(4,6,1))
            {
                acceptNearestMatch = true;
            }
 
            var frameworkPackages = FrameworkPackages.GetFrameworkPackages(nugetFramework, [frameworkReference], acceptNearestMatch)
                .SelectMany(packages => packages)
                .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
 
            return frameworkPackages;
        }
 
        static Dictionary<string, NuGetVersion> LoadPackagesToPruneFromPrunePackageData(string targetFrameworkIdentifier, string targetFrameworkVersion, string frameworkReference, string prunePackageDataRoot)
        {
            if (frameworkReference.Equals("Microsoft.NETCore.App", StringComparison.OrdinalIgnoreCase))
            {
                string packageOverridesPath = Path.Combine(prunePackageDataRoot, targetFrameworkVersion, frameworkReference, "PackageOverrides.txt");
                if (File.Exists(packageOverridesPath))
                {
                    var packageOverrideLines = File.ReadAllLines(packageOverridesPath);
                    var overrides = PackageOverride.CreateOverriddenPackages(packageOverrideLines);
                    return overrides.ToDictionary(o => o.id, o => o.version);
                }
            }
 
            return null;
        }
 
        static Dictionary<string, NuGetVersion> LoadPackagesToPruneFromTargetingPack(Logger log, string targetFrameworkIdentifier, string targetFrameworkVersion, string frameworkReference, string [] targetingPackRoots)
        {
            var nugetFramework = new NuGetFramework(targetFrameworkIdentifier, Version.Parse(targetFrameworkVersion));
 
            foreach (var targetingPackRoot in targetingPackRoots)
            {
                var frameworkPackages = FrameworkPackages.LoadFrameworkPackagesFromPack(log, nugetFramework, frameworkReference, targetingPackRoot)
                    ?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
 
                if (frameworkPackages != null)
                {
                    //  We found the framework packages in the targeting pack, so return them
                    return frameworkPackages;
                }
            }
            return null;
        }
 
        static void AddPackagesToPrune(Dictionary<string, NuGetVersion> packagesToPrune, IEnumerable<(string id, NuGetVersion version)> packagesToAdd, Logger log)
        {
            foreach (var package in packagesToAdd)
            {
                // There are some "inconsistent" versions in the FrameworkPackages data.  This happens because, for example, the ASP.NET Core shared framework for .NET 9 inherits
                // from the ASP.NET Core shared framework for .NET 8, but not from the base Microsoft.NETCore.App framework for .NET 9.  So for something like System.IO.Pipelines,
                // which was in ASP.NET in .NET 8 but moved to the base shared framework in .NET 9, we will see an 8.0 version from the ASP.NET shared framework and a 9.0 version
                // from the base shared framework.  As long as the base shared framework is always referenced together with the ASP.NET shared framework, this shouldn't be a
                // problem, and we can just pick the latest version of the package that we see.
                if (!packagesToPrune.TryGetValue(package.id, out NuGetVersion existingVersion) || package.version > existingVersion)
                {
                    packagesToPrune[package.id] = package.version;
                }
            }
        }
    }
}