File: NuGetSdkResolver.cs
Web Access
Project: src\src\nuget-client\src\NuGet.Core\Microsoft.Build.NuGetSdkResolver\Microsoft.Build.NuGetSdkResolver.csproj (Microsoft.Build.NuGetSdkResolver)
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

#nullable disable

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Microsoft.Build.Framework;
using NuGet.Commands;
using NuGet.Common;
using NuGet.Configuration;
using NuGet.Credentials;
using NuGet.LibraryModel;
using NuGet.Packaging;
#if !NETFRAMEWORK
using NuGet.Packaging.Signing;
#endif
using NuGet.Versioning;

namespace Microsoft.Build.NuGetSdkResolver
{
    /// <summary>
    /// Represents a NuGet-based SDK resolver.  It is very important that this class does not reference any NuGet assemblies
    /// directly as an optimization to avoid loading them unless they are needed.  The current implementation only loads
    /// Newtonsoft.Json if a global.json is found and it contains the msbuild-sdks section and a few NuGet assemblies to parse
    /// a version.  The remaining NuGet assemblies are then loaded to do a restore.
    /// </summary>
    public sealed class NuGetSdkResolver : SdkResolver
    {
        private readonly bool _disableNuGetSdkResolver;

        private static readonly Lazy<object> SettingsLoadContext = new Lazy<object>(() => new SettingsLoadingContext());

        private static readonly Lazy<object> MachineWideSettings = new Lazy<object>(() => new XPlatMachineWideSetting());

        private readonly IGlobalJsonReader _globalJsonReader;

        /// <summary>
        /// Initializes a new instance of the NuGetSdkResolver class.
        /// </summary>
        public NuGetSdkResolver()
            : this(GlobalJsonReader.Instance, EnvironmentVariableWrapper.Instance)
        {
        }

        /// <summary>
        /// Initializes a new instance of the NuGetSdkResolver class with the specified <see cref="IGlobalJsonReader" />.
        /// </summary>
        /// <param name="globalJsonReader">An <see cref="IGlobalJsonReader" /> to use when reading a global.json file.</param>
        /// <param name="environmentVariableReader">An <see cref="IEnvironmentVariableReader" /> to use when reading environment variables.</param>
        internal NuGetSdkResolver(IGlobalJsonReader globalJsonReader, IEnvironmentVariableReader environmentVariableReader)
        {
            _globalJsonReader = globalJsonReader;

            _disableNuGetSdkResolver = environmentVariableReader.GetEnvironmentVariable("MSBUILDDISABLENUGETSDKRESOLVER") == "1";
        }

        /// <inheritdoc />
        public override string Name => nameof(NuGetSdkResolver);

        /// <inheritdoc />
        public override int Priority => 6000;

        /// <summary>Resolves the specified SDK reference from NuGet.</summary>
        /// <param name="sdkReference">A <see cref="T:Microsoft.Build.Framework.SdkReference" /> containing the referenced SDKs be resolved.</param>
        /// <param name="resolverContext">Context for resolving the SDK.</param>
        /// <param name="factory">Factory class to create an <see cref="T:Microsoft.Build.Framework.SdkResult" /></param>
        /// <returns>
        ///     An <see cref="T:Microsoft.Build.Framework.SdkResult" /> containing the resolved SDKs or associated error / reason
        ///     the SDK could not be resolved.  Return <see langword="null" /> if the resolver is not
        ///     applicable for a particular <see cref="T:Microsoft.Build.Framework.SdkReference" />.
        /// </returns>
        public override SdkResult Resolve(SdkReference sdkReference, SdkResolverContext resolverContext, SdkResultFactory factory)
        {
            // Escape hatch to disable this resolver
            if (_disableNuGetSdkResolver)
            {
                // Errors returned to MSBuild aren't logged if another SDK resolver succeeds.  Returning errors on non-succcess is
                // what all SDK resolvers should be doing and if no SDK resolver succeeds then MSBuild logs all of the errors as
                // one.  In this case, the SDK resolver is disabled and it might be helpful for a user to see that they've disabled
                // it in case it was a mistake.
                return factory.IndicateFailure(errors: new List<string>() { Strings.Error_DisabledSdkResolver }, warnings: null);
            }

            if (SdkResolverEventSource.Instance.IsEnabled()) SdkResolverEventSource.Instance.ResolveStart(sdkReference.Name, sdkReference.Version);

            try
            {
                // This resolver only works if the user specifies a version in a project or a global.json.
                // Ignore invalid versions, there may be another resolver that can handle the version specified
                if (!TryGetNuGetVersionForSdk(sdkReference.Name, sdkReference.Version, resolverContext, out var parsedSdkVersion))
                {
                    // Errors returned to MSBuild aren't logged if another SDK resolver succeeds.  Returning errors on non-succcess is
                    // what all SDK resolvers should be doing and if no SDK resolver succeeds then MSBuild logs all of the errors as
                    // one.  In this case, the user might have mispelled global.json or the SDK name in global.json.
                    return factory.IndicateFailure(errors: new List<string>() { Strings.Error_NoSdkVersion }, warnings: null);
                }

                NuGet.Common.Migrations.MigrationRunner.Run();

                return NuGetAbstraction.GetSdkResult(sdkReference, parsedSdkVersion, resolverContext, factory);
            }
            finally
            {
                if (SdkResolverEventSource.Instance.IsEnabled()) SdkResolverEventSource.Instance.ResolveStop(sdkReference.Name, sdkReference.Version);
            }
        }

        /// <summary>
        /// Attempts to determine what version of an SDK to resolve.  A project-specific version is used first and then a version specified in a global.json.
        /// This method should not consume any NuGet classes directly to avoid loading additional assemblies when they are not needed.  This method
        /// returns an object so that NuGetVersion is not consumed directly.
        /// </summary>
        internal bool TryGetNuGetVersionForSdk(string id, string version, SdkResolverContext context, out object parsedVersion)
        {
            if (!string.IsNullOrWhiteSpace(version))
            {
                // Use the version specified in the project if it is a NuGet compatible version
                return NuGetAbstraction.TryParseNuGetVersion(version, out parsedVersion);
            }

            parsedVersion = null;

            // Don't try to find versions defined in global.json if the project full path isn't set because an in-memory project is being evaluated and there's no
            // way to be sure where to look
            if (string.IsNullOrWhiteSpace(context?.ProjectFilePath))
            {
                return false;
            }

            Dictionary<string, string> msbuildSdkVersions = _globalJsonReader.GetMSBuildSdkVersions(context);

            // Check if global.json specified a version for this SDK and make sure its a version compatible with NuGet
            if (msbuildSdkVersions != null && msbuildSdkVersions.TryGetValue(id, out var globalJsonVersion) &&
                !string.IsNullOrWhiteSpace(globalJsonVersion))
            {
                return NuGetAbstraction.TryParseNuGetVersion(globalJsonVersion, out parsedVersion);
            }

            return false;
        }

        /// <summary>
        /// IMPORTANT: This class is used to ensure that <see cref="NuGetSdkResolver"/> does not consume any NuGet classes directly.  This ensures that no NuGet assemblies
        /// are loaded unless they are needed.  Do not implement anything in <see cref="NuGetSdkResolver"/> that uses a NuGet class and instead place it here.
        /// </summary>
        private static class NuGetAbstraction
        {
            public static SdkResult GetSdkResult(SdkReference sdk, object nuGetVersion, SdkResolverContext context, SdkResultFactory factory)
            {
                var logger = new NuGetSdkLogger(context.Logger);

                // Cast the NuGet version since the caller does not want to consume NuGet classes directly
                var parsedSdkVersion = (NuGetVersion)nuGetVersion;

                if (SdkResolverEventSource.Instance.IsEnabled()) SdkResolverEventSource.Instance.GetResultStart(sdk.Name, parsedSdkVersion.OriginalVersion);

                SdkResult result = null;

                try
                {
                    if (SdkResolverEventSource.Instance.IsEnabled()) SdkResolverEventSource.Instance.LoadSettingsStart();

                    // Load NuGet settings and a path resolver
                    ISettings settings;
                    try
                    {
                        settings = Settings.LoadDefaultSettings(context.ProjectFilePath, configFileName: null, MachineWideSettings.Value as IMachineWideSettings, SettingsLoadContext.Value as SettingsLoadingContext);
                    }
                    catch (Exception e)
                    {
                        logger.LogError(string.Format(CultureInfo.CurrentCulture, Strings.Error_FailedToReadSettings, e.Message));

                        result = factory.IndicateFailure(logger.Errors, logger.Warnings);

                        return result;
                    }
                    finally
                    {
                        if (SdkResolverEventSource.Instance.IsEnabled()) SdkResolverEventSource.Instance.LoadSettingsStop();
                    }

                    var fallbackPackagePathResolver = new FallbackPackagePathResolver(NuGetPathContext.Create(settings));

                    var libraryIdentity = new LibraryIdentity(sdk.Name, parsedSdkVersion, LibraryType.Package);

                    // Attempt to find a package if its already installed
                    if (!TryGetMSBuildSdkPackageInfo(fallbackPackagePathResolver, libraryIdentity, out var installedPath, out var installedVersion))
                    {
                        try
                        {
                            DefaultCredentialServiceUtility.SetupDefaultCredentialService(logger, nonInteractive: !context.Interactive);

#if !NETFRAMEWORK
                            X509TrustStore.InitializeForDotNetSdk(logger);
#endif

                            if (SdkResolverEventSource.Instance.IsEnabled()) SdkResolverEventSource.Instance.RestorePackageStart(libraryIdentity.Name, libraryIdentity.Version.OriginalVersion);

                            // Asynchronously run the restore without a commit which find the package on configured feeds, download, and unzip it without generating any other files
                            // This must be run in its own task because legacy project system evaluates projects on the UI thread which can cause RunWithoutCommit() to deadlock
                            // https://developercommunity.visualstudio.com/content/problem/311379/solution-load-never-completes-when-project-contain.html
                            var restoreTask = Task.Run(() => RestoreRunnerEx.RunWithoutCommit(
                                libraryIdentity,
                                settings,
                                logger));

                            var results = restoreTask.Result;

                            if (SdkResolverEventSource.Instance.IsEnabled()) SdkResolverEventSource.Instance.RestorePackageStop(libraryIdentity.Name, libraryIdentity.Version.OriginalVersion);

                            fallbackPackagePathResolver = new FallbackPackagePathResolver(NuGetPathContext.Create(settings));

                            // Look for a successful result, any errors are logged by NuGet
                            foreach (RestoreResult restoreResult in results.Select(i => i.Result).Where(i => i.Success))
                            {
                                // Find the information about the package that was installed.  In some cases, the version can be different than what was specified (like you specify 1.0 but get 1.0.0)
                                var installedPackage = restoreResult.LockFile.GetLibrary(libraryIdentity.Name, libraryIdentity.Version);

                                if (installedPackage != null)
                                {
                                    if (TryGetMSBuildSdkPackageInfo(fallbackPackagePathResolver, libraryIdentity, out installedPath, out installedVersion))
                                    {
                                        break;
                                    }

                                    // This should never happen because we were told the package was successfully installed.
                                    // If we can't find it, we probably did something wrong with the NuGet API
                                    logger.LogError(string.Format(CultureInfo.CurrentCulture, Strings.CouldNotFindInstalledPackage, sdk));
                                }
                                else
                                {
                                    // This should never happen because we were told the restore succeeded.
                                    // If we can't find the package from GetAllInstalled(), we probably did something wrong with the NuGet API
                                    logger.LogError(string.Format(CultureInfo.CurrentCulture, Strings.PackageWasNotInstalled, sdk, sdk.Name));
                                }
                            }
                        }
                        catch (Exception e)
                        {
                            logger.LogError(e.ToString());
                        }
                        finally
                        {
                            // The CredentialService lifetime is for the duration of the process. We should not leave a potentially unavailable logger. 
                            DefaultCredentialServiceUtility.UpdateCredentialServiceDelegatingLogger(NullLogger.Instance);
                        }
                    }

                    if (logger.Errors.Count == 0)
                    {
                        result = factory.IndicateSuccess(path: installedPath, version: installedVersion, warnings: logger.Warnings);

                        return result;
                    }

                    result = factory.IndicateFailure(logger.Errors, logger.Warnings);

                    return result;
                }
                finally
                {
                    if (SdkResolverEventSource.Instance.IsEnabled()) SdkResolverEventSource.Instance.GetResultStop(sdk.Name, parsedSdkVersion.OriginalVersion, result?.Path, result == null ? 0 : (result.Success ? 1 : 0));
                }
            }

            /// <summary>
            /// Attempts to parse a string as a NuGetVersion and returns an object containing the instance which can be cast later.
            /// </summary>
            [MethodImpl(MethodImplOptions.NoInlining)]
            public static bool TryParseNuGetVersion(string version, out object parsed)
            {
                if (NuGetVersion.TryParse(version, out var nuGetVersion))
                {
                    parsed = nuGetVersion;

                    return true;
                }

                parsed = null;
                return false;
            }

            /// <summary>
            /// Attempts to find a NuGet package if it is already installed.
            /// </summary>
            private static bool TryGetMSBuildSdkPackageInfo(FallbackPackagePathResolver fallbackPackagePathResolver, LibraryIdentity libraryIdentity, out string installedPath, out string installedVersion)
            {
                // Find the package
                var packageInfo = fallbackPackagePathResolver.GetPackageInfo(libraryIdentity.Name, libraryIdentity.Version);

                if (packageInfo == null)
                {
                    installedPath = null;
                    installedVersion = null;
                    return false;
                }

                // Get the installed path and add the expected "Sdk" folder.  Windows file systems are not case sensitive
                installedPath = Path.Combine(packageInfo.PathResolver.GetInstallPath(packageInfo.Id, packageInfo.Version), "Sdk");


                if (!NuGet.Common.RuntimeEnvironmentHelper.IsWindows && !Directory.Exists(installedPath))
                {
                    // Fall back to lower case "sdk" folder in case the file system is case sensitive
                    installedPath = Path.Combine(packageInfo.PathResolver.GetInstallPath(packageInfo.Id, packageInfo.Version), "sdk");
                }

                installedVersion = packageInfo.Version.ToString();

                return true;
            }
        }
    }
}