File: Plugins\PluginManager.cs
Web Access
Project: src\src\nuget-client\src\NuGet.Core\NuGet.Protocol\NuGet.Protocol.csproj (NuGet.Protocol)
// 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.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using NuGet.Common;
using NuGet.Configuration;
using NuGet.Packaging;
using NuGet.Protocol.Core.Types;
using NuGet.Shared;

namespace NuGet.Protocol.Plugins
{
    /// <summary>
    /// A plugin manager. This manages all the live plugins and their operation claims.
    /// Invoked in by both the credential provider and the PluginResourceProvider
    /// </summary>
    public sealed class PluginManager : IPluginManager, IDisposable
    {
        private static readonly Lazy<IPluginManager> _lazy = new Lazy<IPluginManager>(() => new PluginManager());
        public static IPluginManager Instance => _lazy.Value;

        private ConnectionOptions _connectionOptions;
        private Lazy<IPluginDiscoverer> _discoverer;
        private bool _isDisposed;
        private IPluginFactory _pluginFactory;
        private ConcurrentDictionary<PluginRequestKey, Lazy<Task<IReadOnlyList<OperationClaim>>>> _pluginOperationClaims;
        private ConcurrentDictionary<string, Lazy<IPluginMulticlientUtilities>> _pluginUtilities;

        private static Lazy<int> _currentProcessId = new Lazy<int>(GetCurrentProcessId);
        private Lazy<string> _pluginsCacheDirectoryPath;

        /// <summary>
        /// Gets an environment variable reader.
        /// </summary>
        /// <remarks>This is non-private only to facilitate testing.</remarks>
        public IEnvironmentVariableReader EnvironmentVariableReader { get; private set; }

        private PluginManager()
        {
            Initialize(
                EnvironmentVariableWrapper.Instance,
                new Lazy<IPluginDiscoverer>(InitializeDiscoverer),
                (TimeSpan idleTimeout) => new PluginFactory(idleTimeout),
                new Lazy<string>(() => SettingsUtility.GetPluginsCacheFolder()));
        }

        public PluginManager(
            IEnvironmentVariableReader reader,
            Lazy<IPluginDiscoverer> pluginDiscoverer,
            Func<TimeSpan, PluginFactory> pluginFactoryCreator,
            Lazy<string> pluginsCacheDirectoryPath)
        {
            Initialize(
                reader,
                pluginDiscoverer,
                pluginFactoryCreator,
                pluginsCacheDirectoryPath);
        }

        /// <summary>
        /// Disposes of this instance.
        /// This should not be called in production code as this is a singleton instance.
        /// The pattern is implemented because the plugin manager transitively owns objects
        /// that need to implement IDisposable because they potentially have managed and unmanaged resources.
        /// </summary>
        public void Dispose()
        {
            if (!_isDisposed)
            {
                if (_discoverer.IsValueCreated)
                {
                    _discoverer.Value.Dispose();
                }

                _pluginFactory.Dispose();

                GC.SuppressFinalize(this);

                _isDisposed = true;
            }
        }

        /// <summary>
        /// Find all available plugins on the machine
        /// </summary>
        /// <param name="cancellationToken"></param>
        /// <returns>PluginDiscoveryResults</returns>
        public async Task<IEnumerable<PluginDiscoveryResult>> FindAvailablePluginsAsync(CancellationToken cancellationToken)
        {
            return await _discoverer.Value.DiscoverAsync(cancellationToken);
        }

        /// <summary>
        /// Create plugins appropriate for the given source
        /// </summary>
        /// <param name="source"></param>
        /// <param name="cancellationToken"></param>
        /// <exception cref="ArgumentNullException">Throw if <paramref name="source"/> is null </exception>
        /// <returns>PluginCreationResults</returns>
        public async Task<IEnumerable<PluginCreationResult>> CreatePluginsAsync(
            SourceRepository source,
            CancellationToken cancellationToken)
        {
            if (source == null)
            {
                throw new ArgumentNullException(nameof(source));
            }

            cancellationToken.ThrowIfCancellationRequested();
            var pluginCreationResults = new List<PluginCreationResult>();

            // Fast path
            if (source.PackageSource.IsHttp && IsPluginPossiblyAvailable())
            {
                var serviceIndex = await source.GetResourceAsync<ServiceIndexResourceV3>(cancellationToken);

                if (serviceIndex != null)
                {
                    var serviceIndexJson = JObject.Parse(serviceIndex.Json);

                    foreach (var result in await FindAvailablePluginsAsync(cancellationToken))
                    {
                        var pluginCreationResult = await TryCreatePluginAsync(
                            result,
                            OperationClaim.DownloadPackage,
                            new PluginRequestKey(result.PluginFile.Path, source.PackageSource.Source),
                            source.PackageSource.Source,
                            serviceIndexJson,
                            cancellationToken);

                        if (pluginCreationResult.Item1)
                        {
                            pluginCreationResults.Add(pluginCreationResult.Item2);
                        }
                    }
                }
            }

            return pluginCreationResults;
        }

        /// <summary>
        /// Creates a plugin from the given pluginDiscoveryResult.
        /// This plugin's operations will be source agnostic ones (Authentication)
        /// </summary>
        /// <param name="pluginDiscoveryResult">plugin discovery result</param>
        /// <param name="requestedOperationClaim">The requested operation claim</param>
        /// <param name="cancellationToken">cancellation token</param>
        /// <returns>A plugin creation result, null if the requested plugin cannot handle the given operation claim</returns>
        public Task<Tuple<bool, PluginCreationResult>> TryGetSourceAgnosticPluginAsync(PluginDiscoveryResult pluginDiscoveryResult, OperationClaim requestedOperationClaim, CancellationToken cancellationToken)
        {
            if (pluginDiscoveryResult == null)
            {
                throw new ArgumentNullException(nameof(pluginDiscoveryResult));
            }

            return TryCreatePluginAsync(
                pluginDiscoveryResult,
                requestedOperationClaim,
                new PluginRequestKey(pluginDiscoveryResult.PluginFile.Path, "Source-Agnostic"),
                packageSourceRepository: null,
                serviceIndex: null,
                cancellationToken: cancellationToken);
        }

        /// <summary>
        /// Creates a plugin from the discovered plugin.
        /// We firstly check the cache for the operation claims for the given request key.
        /// If there is a valid cache entry, and it does contain the requested operation claim, then we start the plugin, and if need be update the cache value itself.
        /// If there is a valid cache entry, and it does NOT contain the requested operation claim, then we return a null.
        /// If there is no valid cache entry or an invalid one, we start the plugin as normally, return an active plugin even if the requested claim is not available, and write a cache entry.
        /// </summary>
        /// <param name="result">plugin discovery result</param>
        /// <param name="requestedOperationClaim">The requested operation claim</param>
        /// <param name="requestKey">plugin request key</param>
        /// <param name="packageSourceRepository">package source repository</param>
        /// <param name="serviceIndex">service index</param>
        /// <param name="cancellationToken">cancellation token</param>
        /// <returns>A plugin creation result, null if the requested plugin cannot handle the given operation claim</returns>
        private async Task<Tuple<bool, PluginCreationResult>> TryCreatePluginAsync(
            PluginDiscoveryResult result,
            OperationClaim requestedOperationClaim,
            PluginRequestKey requestKey,
            string packageSourceRepository,
            JObject serviceIndex,
            CancellationToken cancellationToken)
        {
            // This is a non cancellable task.
            // We should only honor cancellation requests we can recover from. 
            // Once we have reached this part of the code, we do the plugin initialization
            // handshake, operation claims, and shut down set up. 
            // If either one of these tasks fails then the plugin itself is not usable for the rest of the process. 
            // We could consider handling each of this operations more cleverly, 
            // but simplicity and readability is prioritized           
            cancellationToken = CancellationToken.None;
            PluginCreationResult pluginCreationResult = null;
            var cacheEntry = new PluginCacheEntry(_pluginsCacheDirectoryPath.Value, result.PluginFile.Path, requestKey.PackageSourceRepository);

            ConcurrencyUtilities.ExecuteWithFileLocked(cacheEntry.CacheFileName, cacheEntry.LoadFromFile);

            if (cacheEntry.OperationClaims == null || cacheEntry.OperationClaims.Contains(requestedOperationClaim))
            {
                try
                {
                    if (result.PluginFile.State.Value == PluginFileState.Valid)
                    {
                        IPlugin plugin = await _pluginFactory.GetOrCreateAsync(
                                pluginFile: result.PluginFile,
                                arguments: PluginConstants.PluginArguments,
                                requestHandlers: new RequestHandlers(),
                                options: _connectionOptions,
                                sessionCancellationToken: cancellationToken);

                        var utilities = await PerformOneTimePluginInitializationAsync(plugin, cancellationToken);

                        // We still make the GetOperationClaims call even if we have the operation claims cached. This is a way to self-update the cache.
                        var operationClaims = await _pluginOperationClaims.GetOrAdd(
                            requestKey,
                            key => new Lazy<Task<IReadOnlyList<OperationClaim>>>(() =>
                            GetPluginOperationClaimsAsync(
                                plugin,
                                packageSourceRepository,
                                serviceIndex,
                                cancellationToken))).Value;

                        if (!EqualityUtility.SequenceEqualWithNullCheck(operationClaims, cacheEntry.OperationClaims))
                        {
                            cacheEntry.OperationClaims = operationClaims;

                            await utilities.Value.DoOncePerPluginLifetimeAsync(
                                nameof(PluginCacheEntry),
                                () => ConcurrencyUtilities.ExecuteWithFileLockedAsync(
                                    cacheEntry.CacheFileName,
                                    action: async lockedToken =>
                                    {
                                        await cacheEntry.UpdateCacheFileAsync();

                                        return TaskResult.Null<object>();
                                    },
                                    token: cancellationToken),
                                cancellationToken);
                        }

                        pluginCreationResult = new PluginCreationResult(
                            plugin,
                            utilities.Value,
                            operationClaims);
                    }
                    else
                    {
                        pluginCreationResult = new PluginCreationResult(result.Message);
                    }
                }
                catch (Exception e)
                {
                    pluginCreationResult = new PluginCreationResult(
                        string.Format(CultureInfo.CurrentCulture,
                            Strings.Plugin_ProblemStartingPlugin,
                            result.PluginFile.Path,
                            e.Message),
                        e);
                }
            }

            return new Tuple<bool, PluginCreationResult>(pluginCreationResult != null, pluginCreationResult);
        }

        private async Task<Lazy<IPluginMulticlientUtilities>> PerformOneTimePluginInitializationAsync(IPlugin plugin, CancellationToken cancellationToken)
        {
            var utilities = _pluginUtilities.GetOrAdd(
                plugin.Id,
                path => new Lazy<IPluginMulticlientUtilities>(
                    () => new PluginMulticlientUtilities()));

            plugin.Closed += OnPluginClosed;

            await utilities.Value.DoOncePerPluginLifetimeAsync(
                MessageMethod.MonitorNuGetProcessExit.ToString(),
                () => plugin.Connection.SendRequestAndReceiveResponseAsync<MonitorNuGetProcessExitRequest, MonitorNuGetProcessExitResponse>(
                    MessageMethod.MonitorNuGetProcessExit,
                    new MonitorNuGetProcessExitRequest(_currentProcessId.Value),
                    cancellationToken),
                cancellationToken);

            await utilities.Value.DoOncePerPluginLifetimeAsync(
                MessageMethod.Initialize.ToString(),
                () => InitializePluginAsync(plugin, _connectionOptions.RequestTimeout, cancellationToken),
                cancellationToken);

            return utilities;
        }

        private void Initialize(IEnvironmentVariableReader reader,
            Lazy<IPluginDiscoverer> pluginDiscoverer,
            Func<TimeSpan, IPluginFactory> pluginFactoryCreator,
            Lazy<string> pluginsCacheDirectoryPath)
        {
            EnvironmentVariableReader = reader ?? throw new ArgumentNullException(nameof(reader));
            _discoverer = pluginDiscoverer ?? throw new ArgumentNullException(nameof(pluginDiscoverer));
            _pluginsCacheDirectoryPath = pluginsCacheDirectoryPath ?? throw new ArgumentNullException(nameof(pluginsCacheDirectoryPath));

            if (pluginFactoryCreator == null)
            {
                throw new ArgumentNullException(nameof(pluginFactoryCreator));
            }

            _connectionOptions = ConnectionOptions.CreateDefault(reader);

            var idleTimeoutInSeconds = EnvironmentVariableReader.GetEnvironmentVariable(EnvironmentVariableConstants.IdleTimeout);
            var idleTimeout = TimeoutUtilities.GetTimeout(idleTimeoutInSeconds, PluginConstants.IdleTimeout);

            _pluginFactory = pluginFactoryCreator(idleTimeout);
            _pluginOperationClaims = new ConcurrentDictionary<PluginRequestKey, Lazy<Task<IReadOnlyList<OperationClaim>>>>();
            _pluginUtilities = new ConcurrentDictionary<string, Lazy<IPluginMulticlientUtilities>>(
                StringComparer.OrdinalIgnoreCase);
        }

        private async Task<IReadOnlyList<OperationClaim>> GetPluginOperationClaimsAsync(
            IPlugin plugin,
            string packageSourceRepository,
            JObject serviceIndex,
            CancellationToken cancellationToken)
        {
            if (plugin.Connection.ProtocolVersion.Equals(Plugins.ProtocolConstants.Version100) && (string.IsNullOrEmpty(packageSourceRepository) || serviceIndex == null))
            {
                throw new ArgumentException("Cannot invoke get operation claims with null arguments on a " + Plugins.ProtocolConstants.Version100 + " plugin");
            }

            var payload = new GetOperationClaimsRequest(packageSourceRepository, serviceIndex);

            var response = await plugin.Connection.SendRequestAndReceiveResponseAsync<GetOperationClaimsRequest, GetOperationClaimsResponse>(
                MessageMethod.GetOperationClaims,
                payload,
                cancellationToken);
            if (response == null)
            {
                return Array.Empty<OperationClaim>();
            }

            return response.Claims;
        }

        private PluginDiscoverer InitializeDiscoverer()
        {
            return new PluginDiscoverer();
        }

        private bool IsPluginPossiblyAvailable()
        {
            string pluginEnvVariable;

#if IS_DESKTOP
            pluginEnvVariable = EnvironmentVariableReader.GetEnvironmentVariable(EnvironmentVariableConstants.DesktopPluginPaths);
#else
            pluginEnvVariable = EnvironmentVariableReader.GetEnvironmentVariable(EnvironmentVariableConstants.CorePluginPaths);
#endif
            pluginEnvVariable ??= EnvironmentVariableReader.GetEnvironmentVariable(EnvironmentVariableConstants.PluginPaths);
            return !string.IsNullOrEmpty(pluginEnvVariable);
        }

        private void OnPluginClosed(object sender, EventArgs e)
        {
            if (sender is IPlugin plugin)
            {
                plugin.Closed -= OnPluginClosed;

                _pluginUtilities.TryRemove(plugin.Id, out Lazy<IPluginMulticlientUtilities> utilities);
            }
        }

        private static int GetCurrentProcessId()
        {
            using (var process = Process.GetCurrentProcess())
            {
                return process.Id;
            }
        }

        /// <summary>
        /// Gets the current culture.
        /// An invariant culture's name will be "". Since InitializeRequest has a null or empty check, this can be a problem.
        /// Because the InitializeRequest message is part of a protocol, and the reason why we set the culture is allow the plugins to localize their messages,
        /// we can safely default to en. 
        /// </summary>
        /// <returns>CurrentCulture or an en default if the current culture is invariant</returns>
        private static string GetCurrentCultureName()
        {
            var currentCultureName = CultureInfo.CurrentCulture.Name;
            if (string.IsNullOrEmpty(currentCultureName))
            {
                currentCultureName = "en";
            }
            return currentCultureName;
        }

        private static async Task InitializePluginAsync(
            IPlugin plugin,
            TimeSpan requestTimeout,
            CancellationToken cancellationToken)
        {
            var clientVersion = MinClientVersionUtility.GetNuGetClientVersion().ToNormalizedString();
            var culture = GetCurrentCultureName();

            var payload = new InitializeRequest(
                clientVersion,
                culture,
                requestTimeout);

            var response = await plugin.Connection.SendRequestAndReceiveResponseAsync<InitializeRequest, InitializeResponse>(
                MessageMethod.Initialize,
                payload,
                cancellationToken);

            if (response != null && response.ResponseCode != MessageResponseCode.Success)
            {
                throw new PluginException(Strings.Plugin_InitializationFailed);
            }

            plugin.Connection.Options.SetRequestTimeout(requestTimeout);
        }

        private sealed class PluginRequestKey : IEquatable<PluginRequestKey>
        {
            internal string PluginFilePath { get; }
            internal string PackageSourceRepository { get; }

            internal PluginRequestKey(string pluginFilePath, string packageSourceRepository)
            {
                PluginFilePath = pluginFilePath;
                PackageSourceRepository = packageSourceRepository;
            }

            public override bool Equals(object obj)
            {
                return Equals(obj as PluginRequestKey);
            }

            public override int GetHashCode()
            {
                return HashCodeCombiner.GetHashCode(PluginFilePath, PackageSourceRepository);
            }

            public bool Equals(PluginRequestKey other)
            {
                if (ReferenceEquals(this, other))
                {
                    return true;
                }

                if (ReferenceEquals(null, other))
                {
                    return false;
                }

                return PathUtility.GetStringComparerBasedOnOS().Equals(PluginFilePath, other.PluginFilePath)
                    && string.Equals(
                        PackageSourceRepository,
                        other.PackageSourceRepository,
                        StringComparison.OrdinalIgnoreCase);
            }
        }
    }
}