File: MSBuildSdkResolver.cs
Web Access
Project: ..\..\..\src\Resolvers\Microsoft.DotNet.MSBuildSdkResolver\Microsoft.DotNet.MSBuildSdkResolver.csproj (Microsoft.DotNet.MSBuildSdkResolver)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Reflection;
using System.Text.Json;
using Microsoft.Build.Framework;
using Microsoft.DotNet.Cli;
using Microsoft.DotNet.Configurer;
using Microsoft.DotNet.DotNetSdkResolver;
using Microsoft.DotNet.NativeWrapper;
using Microsoft.NET.Sdk.WorkloadMSBuildSdkResolver;
using static Microsoft.NET.Sdk.WorkloadMSBuildSdkResolver.CachingWorkloadResolver;
 
namespace Microsoft.DotNet.MSBuildSdkResolver
{
    // Thread-safety note:
    //  1. MSBuild can call the same resolver instance in parallel on multiple threads.
    //  2. Nevertheless, in the IDE, project re-evaluation can create new instances for each evaluation.
    //
    // As such, all state (instance or static) must be guarded against concurrent access/updates.
    // VSSettings are also effectively static (singleton instance that can be swapped by tests).
 
    public sealed class DotNetMSBuildSdkResolver : SdkResolver
    {
        public override string Name => "Microsoft.DotNet.MSBuildSdkResolver";
 
        // Default resolver has priority 10000 and we want to go before it and leave room on either side of us.
        public override int Priority => 5000;
 
        private readonly Func<string, string?> _getEnvironmentVariable;
        private readonly Func<string>? _getCurrentProcessPath;
        private readonly Func<string, string, string?> _getMsbuildRuntime;
        private readonly NETCoreSdkResolver _netCoreSdkResolver;
 
        private const string DOTNET_HOST = nameof(DOTNET_HOST);
        private const string DotnetHostExperimentalKey = "DOTNET_EXPERIMENTAL_HOST_PATH";
        private const string MSBuildTaskHostRuntimeVersion = "SdkResolverMSBuildTaskHostRuntimeVersion";
        private const string SdkResolverHonoredGlobalJson = "SdkResolverHonoredGlobalJson";
        private const string SdkResolverGlobalJsonPath = "SdkResolverGlobalJsonPath";
        private static CachingWorkloadResolver _staticWorkloadResolver = new();
 
        /// <summary>
        /// This is a workaround for MSBuild API compatibility in older VS hosts.
        /// To allow for working with the MSBuild 17.15 APIs while not blowing up our ability to build the SdkResolver in
        /// VS-driven CI pipelines, we probe and invoke only if the expected method is present.
        /// Once we can update our MSBuild API dependency this can go away.
        /// </summary>
        private static UpdatedSdkResultFactorySuccess? _factorySuccessFunc = TryLocateNewMSBuildFactory();
 
        /// <summary>
        /// This represents the 'open delegate' form of the updated SdkResultFactory.IndicateSuccess method with environment variable support.
        /// Because it is an open delegate, we can provide an object instance to be called as the first argument.
        /// </summary>
        public delegate SdkResult UpdatedSdkResultFactorySuccess(SdkResultFactory factory, string sdkPath, string? sdkVersion, IDictionary<string, string?>? propertiesToAdd, IDictionary<string, SdkResultItem>? itemsToAdd, List<string>? warnings, IDictionary<string, string?>? environmentVariablesToAdd);
 
        private static UpdatedSdkResultFactorySuccess? TryLocateNewMSBuildFactory()
        {
            if (typeof(SdkResultFactory).GetMethod("IndicateSuccess", [
                typeof(string), // path to sdk
                typeof(string), // sdk version
                typeof(IDictionary<string, string>), // properties to add
                typeof(IDictionary<string, SdkResultItem>), // items to add
                typeof(List<string>), // warnings
                typeof(IDictionary<string, string>) // environment variables to add
            ]) is MethodInfo m)
            {
                return Delegate.CreateDelegate(
                    typeof(Func<SdkResultFactory, string, string?, IDictionary<string, string?>?, IDictionary<string, SdkResultItem>?, List<string>?, IDictionary<string, string?>?, SdkResult>),
                    null,
                    m) as UpdatedSdkResultFactorySuccess;
            }
            return null;
        }
 
        private bool _shouldLog = false;
 
        public DotNetMSBuildSdkResolver()
            : this(Environment.GetEnvironmentVariable, null, GetMSbuildRuntimeVersion, VSSettings.Ambient)
        {
        }
 
        // Test constructor
        public DotNetMSBuildSdkResolver(Func<string, string?> getEnvironmentVariable, Func<string>? getCurrentProcessPath, Func<string, string, string?> getMsbuildRuntime, VSSettings vsSettings)
        {
            _getEnvironmentVariable = getEnvironmentVariable;
            _getCurrentProcessPath = getCurrentProcessPath;
            _netCoreSdkResolver = new NETCoreSdkResolver(getEnvironmentVariable, vsSettings);
            _getMsbuildRuntime = getMsbuildRuntime;
 
            if (_getEnvironmentVariable(EnvironmentVariableNames.DOTNET_MSBUILD_SDK_RESOLVER_ENABLE_LOG) is string val &&
                (string.Equals(val, "true", StringComparison.OrdinalIgnoreCase) ||
                 string.Equals(val, "1", StringComparison.Ordinal)))
            {
                _shouldLog = true;
            }
        }
 
        private sealed class CachedState
        {
            public string? DotnetRoot;
            public string? MSBuildSdksDir;
            public string? NETCoreSdkVersion;
            public string? GlobalJsonPath;
            public IDictionary<string, string?>? PropertiesToAdd;
            public CachingWorkloadResolver? WorkloadResolver;
            public IDictionary<string, string?>? EnvironmentVariablesToAdd;
        }
 
        public override SdkResult? Resolve(SdkReference sdkReference, SdkResolverContext context, SdkResultFactory factory)
        {
            string? dotnetRoot = null;
            string? msbuildSdksDir = null;
            string? netcoreSdkVersion = null;
            string? globalJsonPath = null;
            IDictionary<string, string?>? propertiesToAdd = null;
            IDictionary<string, SdkResultItem>? itemsToAdd = null;
            IDictionary<string, string?>? environmentVariablesToAdd = null;
            List<string>? warnings = null;
            CachingWorkloadResolver? workloadResolver = null;
 
            ResolverLogger? logger = null;
            if (_shouldLog)
            {
                logger = new ResolverLogger();
            }
 
            logger?.LogMessage($"Attempting to resolve MSBuild SDK {sdkReference.Name}");
 
            if (context.State is CachedState priorResult)
            {
                logger?.LogString("Using previously cached state");
 
                dotnetRoot = priorResult.DotnetRoot;
                msbuildSdksDir = priorResult.MSBuildSdksDir;
                netcoreSdkVersion = priorResult.NETCoreSdkVersion;
                globalJsonPath = priorResult.GlobalJsonPath;
                propertiesToAdd = priorResult.PropertiesToAdd;
                workloadResolver = priorResult.WorkloadResolver;
                environmentVariablesToAdd = priorResult.EnvironmentVariablesToAdd;
 
                logger?.LogMessage($"\tDotnet root: {dotnetRoot}");
                logger?.LogMessage($"\tMSBuild SDKs Dir: {msbuildSdksDir}");
                logger?.LogMessage($"\t.NET Core SDK Version: {netcoreSdkVersion}");
            }
 
            if (context.IsRunningInVisualStudio)
            {
                logger?.LogString("Running in Visual Studio, using static workload resolver");
                workloadResolver = _staticWorkloadResolver;
            }
 
            if (workloadResolver == null)
            {
                workloadResolver = new CachingWorkloadResolver();
            }
 
            if (msbuildSdksDir == null)
            {
                dotnetRoot = EnvironmentProvider.GetDotnetExeDirectory(_getEnvironmentVariable, _getCurrentProcessPath, logger != null ? logger.LogMessage : null);
                logger?.LogMessage($"\tDotnet root: {dotnetRoot}");
 
                logger?.LogString("Resolving .NET Core SDK directory");
                string? globalJsonStartDir = GetGlobalJsonStartDir(context);
                logger?.LogMessage($"\tglobal.json start directory: {globalJsonStartDir}");
                var resolverResult = _netCoreSdkResolver.ResolveNETCoreSdkDirectory(globalJsonStartDir, context.MSBuildVersion, context.IsRunningInVisualStudio, dotnetRoot);
 
                if (resolverResult.ResolvedSdkDirectory == null)
                {
                    logger?.LogMessage($"Failed to resolve .NET SDK.  Global.json path: {resolverResult.GlobalJsonPath}");
                    return Failure(
                        factory,
                        logger,
                        context.Logger,
                        Strings.UnableToLocateNETCoreSdk);
                }
 
                logger?.LogMessage($"\tResolved SDK directory: {resolverResult.ResolvedSdkDirectory}");
                logger?.LogMessage($"\tglobal.json path: {resolverResult.GlobalJsonPath}");
                logger?.LogMessage($"\tFailed to resolve SDK from global.json: {resolverResult.FailedToResolveSDKSpecifiedInGlobalJson}");
                string dotnetSdkDir = resolverResult.ResolvedSdkDirectory;
                msbuildSdksDir = Path.Combine(dotnetSdkDir, "Sdks");
                netcoreSdkVersion = new DirectoryInfo(dotnetSdkDir).Name;
                globalJsonPath = resolverResult.GlobalJsonPath;
 
                // These are overrides that are used to force the resolved SDK tasks and targets to come from a given
                // base directory and report a given version to msbuild (which may be null if unknown. One key use case
                // for this is to test SDK tasks and targets without deploying them inside the .NET Core SDK.
                var msbuildSdksDirFromEnv = _getEnvironmentVariable(EnvironmentVariableNames.DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR);
                var netcoreSdkVersionFromEnv = _getEnvironmentVariable(EnvironmentVariableNames.DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER);
                if (!string.IsNullOrEmpty(msbuildSdksDirFromEnv))
                {
                    logger?.LogMessage($"MSBuild SDKs dir overridden via DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR to {msbuildSdksDirFromEnv}");
                    msbuildSdksDir = msbuildSdksDirFromEnv;
                }
                if (!string.IsNullOrEmpty(netcoreSdkVersionFromEnv))
                {
                    logger?.LogMessage($".NET Core SDK version overridden via DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER to {netcoreSdkVersionFromEnv}");
                    netcoreSdkVersion = netcoreSdkVersionFromEnv;
                }
 
                if (IsNetCoreSDKSmallerThanTheMinimumVersion(netcoreSdkVersion, sdkReference.MinimumVersion))
                {
                    return Failure(
                        factory,
                        logger,
                        context.Logger,
                        Strings.NETCoreSDKSmallerThanMinimumRequestedVersion,
                        netcoreSdkVersion,
                        sdkReference.MinimumVersion);
                }
 
                Version minimumMSBuildVersion = _netCoreSdkResolver.GetMinimumMSBuildVersion(resolverResult.ResolvedSdkDirectory);
                if (context.MSBuildVersion < minimumMSBuildVersion)
                {
                    return Failure(
                        factory,
                        logger,
                        context.Logger,
                        Strings.MSBuildSmallerThanMinimumVersion,
                        netcoreSdkVersion,
                        minimumMSBuildVersion,
                        context.MSBuildVersion);
                }
 
                string minimumVSDefinedSDKVersion = GetMinimumVSDefinedSDKVersion();
                if (IsNetCoreSDKSmallerThanTheMinimumVersion(netcoreSdkVersion, minimumVSDefinedSDKVersion))
                {
                    return Failure(
                        factory,
                        logger,
                        context.Logger,
                        Strings.NETCoreSDKSmallerThanMinimumVersionRequiredByVisualStudio,
                        netcoreSdkVersion,
                        minimumVSDefinedSDKVersion);
                }
 
                string? fullPathToMuxer =
                    TryResolveMuxerFromSdkResolution(dotnetSdkDir)
                    ?? Path.Combine(dotnetRoot, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Constants.DotNetExe : Constants.DotNet);
                if (File.Exists(fullPathToMuxer))
                {
                    // keeping this in until this component no longer needs to handle 17.14.
                    propertiesToAdd ??= new Dictionary<string, string?>();
                    propertiesToAdd.Add(DotnetHostExperimentalKey, fullPathToMuxer);
                    // this is the future-facing implementation.
                    environmentVariablesToAdd ??= new Dictionary<string, string?>(1)
                    {
                        [DOTNET_HOST] = fullPathToMuxer
                    };
                }
                else
                {
                    logger?.LogMessage($"Could not set '{DOTNET_HOST}' environment variable because dotnet executable '{fullPathToMuxer}' does not exist.");
                }
 
                string? runtimeVersion = dotnetRoot != null ?
                    _getMsbuildRuntime(dotnetSdkDir, dotnetRoot) :
                    null;
                if (!string.IsNullOrEmpty(runtimeVersion))
                {
                    propertiesToAdd ??= new Dictionary<string, string?>();
                    propertiesToAdd.Add(MSBuildTaskHostRuntimeVersion, runtimeVersion);
                }
                else
                {
                    logger?.LogMessage($"Could not set '{MSBuildTaskHostRuntimeVersion}' because runtime version could not be determined.");
                }
 
                if (resolverResult.FailedToResolveSDKSpecifiedInGlobalJson)
                {
                    logger?.LogMessage($"Could not resolve SDK specified in '{globalJsonPath}'. Ignoring global.json for this resolution.");
 
                    if (warnings == null)
                    {
                        warnings = new List<string>();
                    }
 
                    if (!string.IsNullOrWhiteSpace(resolverResult.RequestedVersion))
                    {
                        warnings.Add(string.Format(Strings.GlobalJsonResolutionFailedSpecificVersion, resolverResult.RequestedVersion));
                    }
                    else
                    {
                        warnings.Add(Strings.GlobalJsonResolutionFailed);
                    }
 
                    propertiesToAdd ??= new Dictionary<string, string?>();
                    propertiesToAdd.Add(SdkResolverHonoredGlobalJson, "false");
                    // TODO: this would ideally be reported anytime it was non-null - that may cause more imports though?
                    propertiesToAdd.Add(SdkResolverGlobalJsonPath, globalJsonPath);
 
                    if (logger != null)
                    {
                        CopyLogMessages(logger, context.Logger);
                    }
                }
            }
 
            context.State = new CachedState
            {
                DotnetRoot = dotnetRoot,
                MSBuildSdksDir = msbuildSdksDir,
                NETCoreSdkVersion = netcoreSdkVersion,
                GlobalJsonPath = globalJsonPath,
                PropertiesToAdd = propertiesToAdd,
                WorkloadResolver = workloadResolver,
                EnvironmentVariablesToAdd = environmentVariablesToAdd
            };
 
            //  First check if requested SDK resolves to a workload SDK pack
            string? userProfileDir = CliFolderPathCalculatorCore.GetDotnetUserProfileFolderPath();
            ResolutionResult? workloadResult = null;
            if (dotnetRoot is not null && netcoreSdkVersion is not null)
            {
                workloadResult = workloadResolver.Resolve(sdkReference.Name, dotnetRoot, netcoreSdkVersion, userProfileDir, globalJsonPath);
            }
 
            if (workloadResult is not CachingWorkloadResolver.NullResolutionResult)
            {
                return workloadResult?.ToSdkResult(sdkReference, factory);
            }
 
            string msbuildSdkDir = Path.Combine(msbuildSdksDir, sdkReference.Name, "Sdk");
            if (!Directory.Exists(msbuildSdkDir))
            {
                return Failure(
                    factory,
                    logger,
                    context.Logger,
                    Strings.MSBuildSDKDirectoryNotFound,
                    msbuildSdkDir);
            }
            if (_factorySuccessFunc != null)
            {
                return _factorySuccessFunc(factory, msbuildSdkDir, netcoreSdkVersion, propertiesToAdd, itemsToAdd, warnings, environmentVariablesToAdd);
            }
            else
            {
                return factory.IndicateSuccess(msbuildSdkDir, netcoreSdkVersion, propertiesToAdd, itemsToAdd, warnings);
            }
        }
 
        /// <summary>
        /// Try to find the muxer binary from the SDK resolution result.
        /// </summary>
        /// <remarks>
        /// SDK layouts always have a defined relationship to the location of the muxer -
        /// the muxer binary should be exactly two directories above the SDK directory.
        /// </remarks>
        private static string? TryResolveMuxerFromSdkResolution(string resolvedSdkDirectory)
        {
            var expectedFileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Constants.DotNetExe : Constants.DotNet;
            var currentDir = resolvedSdkDirectory;
            var expectedDotnetRoot = Path.GetDirectoryName(Path.GetDirectoryName(currentDir));
            var expectedMuxerPath = Path.Combine(expectedDotnetRoot, expectedFileName);
            if (File.Exists(expectedMuxerPath))
            {
                return expectedMuxerPath;
            }
            return null;
        }
 
        private static string? GetMSbuildRuntimeVersion(string sdkDirectory, string dotnetRoot)
        {
            // 1. Get the runtime version from the MSBuild.runtimeconfig.json file
            string runtimeConfigPath = Path.Combine(sdkDirectory, "MSBuild.runtimeconfig.json");
            if (!File.Exists(runtimeConfigPath)) return null;
 
            using var stream = File.OpenRead(runtimeConfigPath);
            using var jsonDoc = JsonDocument.Parse(stream);
 
            JsonElement root = jsonDoc.RootElement;
            if (!root.TryGetProperty("runtimeOptions", out JsonElement runtimeOptions) ||
                !runtimeOptions.TryGetProperty("framework", out JsonElement framework)) return null;
 
            string? runtimeName = framework.GetProperty("name").GetString();
            string? runtimeVersion = framework.GetProperty("version").GetString();
 
            // 2. Check that the runtime version is installed (in shared folder)
            return (!string.IsNullOrEmpty(runtimeName) && !string.IsNullOrEmpty(runtimeVersion) &&
                    Directory.Exists(Path.Combine(dotnetRoot, "shared", runtimeName, runtimeVersion)))
                    ? runtimeVersion : null;
        }
 
        private static SdkResult Failure(SdkResultFactory factory, ResolverLogger? logger, SdkLogger sdkLogger, string format, params object?[] args)
        {
            string error = string.Format(format, args);
 
            if (logger != null)
            {
                logger.LogMessage($"Failed to resolve SDK: {error}");
                CopyLogMessages(logger, sdkLogger);
            }
 
            return factory.IndicateFailure(new[] { error });
        }
 
        private static void CopyLogMessages(ResolverLogger source, SdkLogger destination)
        {
            foreach (var message in source.Messages)
            {
                destination.LogMessage(message.ToString(), MessageImportance.High);
            }
            //  Avoid copying the same messages again if CopyLogMessages is called multiple times
            source.Messages.Clear();
        }
 
        /// <summary>
        /// Gets the starting path to search for global.json.
        /// </summary>
        /// <param name="context">A <see cref="SdkResolverContext" /> that specifies where the current project is located.</param>
        /// <returns>The full path to a starting directory to use when searching for a global.json.</returns>
        private static string? GetGlobalJsonStartDir(SdkResolverContext context)
        {
            // Evaluating in-memory projects with MSBuild means that they won't have a solution or project path.
            // Default to using the current directory as a best effort to finding a global.json.  This could result in
            // using the wrong one but without a starting directory, SDK resolution won't work at all.  In most cases, a
            // global.json won't be found and the default SDK will be used.
 
            string? startDir = Environment.CurrentDirectory;
 
            if (!string.IsNullOrWhiteSpace(context.SolutionFilePath))
            {
                startDir = Path.GetDirectoryName(context.SolutionFilePath);
            }
            else if (!string.IsNullOrWhiteSpace(context.ProjectFilePath))
            {
                startDir = Path.GetDirectoryName(context.ProjectFilePath);
            }
 
            return startDir;
        }
 
        private static string GetMinimumVSDefinedSDKVersion()
        {
            string? dotnetMSBuildSdkResolverDirectory =
                Path.GetDirectoryName(typeof(DotNetMSBuildSdkResolver).GetTypeInfo().Assembly.Location);
 
            string minimumVSDefinedSdkVersionFilePath =
                Path.Combine(dotnetMSBuildSdkResolverDirectory ?? string.Empty, "minimumVSDefinedSDKVersion");
 
            if (!File.Exists(minimumVSDefinedSdkVersionFilePath))
            {
                // smallest version that is required by VS 15.3.
                return "1.0.4";
            }
 
            return File.ReadLines(minimumVSDefinedSdkVersionFilePath).First().Trim();
        }
 
        private bool IsNetCoreSDKSmallerThanTheMinimumVersion(string? netcoreSdkVersion, string minimumVersion)
        {
            FXVersion? netCoreSdkFXVersion;
            FXVersion? minimumFXVersion;
 
            if (string.IsNullOrEmpty(minimumVersion))
            {
                return false;
            }
 
            if (!FXVersion.TryParse(netcoreSdkVersion, out netCoreSdkFXVersion) ||
                netCoreSdkFXVersion is null ||
                !FXVersion.TryParse(minimumVersion, out minimumFXVersion) ||
                minimumFXVersion is null)
            {
                return true;
            }
 
            return FXVersion.Compare(netCoreSdkFXVersion, minimumFXVersion) < 0;
        }
 
 
        class ResolverLogger
        {
            public List<object> Messages = new();
 
            public ResolverLogger()
            {
 
            }
 
            public void LogMessage(FormattableString message)
            {
                Messages.Add(message);
            }
 
            public void LogString(string message)
            {
                Messages.Add(message);
            }
        }
    }
}