diff --git a/src/NuGet.Core/NuGet.Protocol/Plugins/PluginDiscoverer.cs b/src/NuGet.Core/NuGet.Protocol/Plugins/PluginDiscoverer.cs index 830ba4d4142..d342827a370 100644 --- a/src/NuGet.Core/NuGet.Protocol/Plugins/PluginDiscoverer.cs +++ b/src/NuGet.Core/NuGet.Protocol/Plugins/PluginDiscoverer.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using NuGet.Common; @@ -17,17 +18,31 @@ public sealed class PluginDiscoverer : IPluginDiscoverer { private bool _isDisposed; private List _pluginFiles; - private readonly string _rawPluginPaths; + private readonly string _netCoreOrNetFXPluginPaths; + private readonly string _nuGetPluginPaths; private IEnumerable _results; private readonly SemaphoreSlim _semaphore; + private readonly IEnvironmentVariableReader _environmentVariableReader; - /// - /// Instantiates a new class. - /// - /// The raw semicolon-delimited list of supposed plugin file paths. - public PluginDiscoverer(string rawPluginPaths) + public PluginDiscoverer() + : this(EnvironmentVariableWrapper.Instance) { - _rawPluginPaths = rawPluginPaths; + } + + internal PluginDiscoverer(IEnvironmentVariableReader environmentVariableReader) + { + _environmentVariableReader = environmentVariableReader; +#if IS_DESKTOP + _netCoreOrNetFXPluginPaths = environmentVariableReader.GetEnvironmentVariable(EnvironmentVariableConstants.DesktopPluginPaths); +#else + _netCoreOrNetFXPluginPaths = environmentVariableReader.GetEnvironmentVariable(EnvironmentVariableConstants.CorePluginPaths); +#endif + + if (string.IsNullOrEmpty(_netCoreOrNetFXPluginPaths)) + { + _nuGetPluginPaths = _environmentVariableReader.GetEnvironmentVariable(EnvironmentVariableConstants.PluginPaths); + } + _semaphore = new SemaphoreSlim(initialCount: 1, maxCount: 1); } @@ -75,7 +90,40 @@ public async Task> DiscoverAsync(Cancellation return _results; } - _pluginFiles = GetPluginFiles(cancellationToken); + if (!string.IsNullOrEmpty(_netCoreOrNetFXPluginPaths)) + { + // NUGET_NETFX_PLUGIN_PATHS, NUGET_NETCORE_PLUGIN_PATHS have been set. + var filePaths = _netCoreOrNetFXPluginPaths.Split(new[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries); + _pluginFiles = GetPluginFiles(filePaths, cancellationToken); + } + else if (!string.IsNullOrEmpty(_nuGetPluginPaths)) + { + // NUGET_PLUGIN_PATHS has been set + _pluginFiles = GetPluginsInNuGetPluginPaths(); + } + else + { + // restore to default plugins search. + // Search for plugins in %user%/.nuget/plugins + var directories = new List { PluginDiscoveryUtility.GetNuGetHomePluginsPath() }; +#if IS_DESKTOP + // Internal plugins are only supported for .NET Framework scenarios, namely msbuild.exe + directories.Add(PluginDiscoveryUtility.GetInternalPlugins()); +#endif + var filePaths = PluginDiscoveryUtility.GetConventionBasedPlugins(directories); + _pluginFiles = GetPluginFiles(filePaths, cancellationToken); + + // Search for .Net tools plugins in PATH + if (_pluginFiles != null) + { + _pluginFiles.AddRange(GetPluginsInPath()); + } + else + { + _pluginFiles = GetPluginsInPath(); + } + } + var results = new List(); for (var i = 0; i < _pluginFiles.Count; ++i) @@ -97,14 +145,17 @@ public async Task> DiscoverAsync(Cancellation return _results; } - private List GetPluginFiles(CancellationToken cancellationToken) + private static List GetPluginFiles(IEnumerable filePaths, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var filePaths = GetPluginFilePaths(); - var files = new List(); + if (filePaths == null) + { + return files; + } + foreach (var filePath in filePaths) { var pluginFile = new PluginFile(filePath, new Lazy(() => @@ -124,19 +175,173 @@ private List GetPluginFiles(CancellationToken cancellationToken) return files; } - private IEnumerable GetPluginFilePaths() + /// + /// Retrieves authentication plugins by searching through directories and files specified in the `NuGET_PLUGIN_PATHS` + /// environment variable. The method looks for files prefixed with 'nuget-plugin-' and verifies their validity for .net tools plugins. + /// + /// A list of valid objects representing the discovered plugins. + internal List GetPluginsInNuGetPluginPaths() { - if (string.IsNullOrEmpty(_rawPluginPaths)) + var pluginFiles = new List(); + string[] paths = _nuGetPluginPaths?.Split(Path.PathSeparator) ?? Array.Empty(); + + foreach (var path in paths) { - var directories = new List { PluginDiscoveryUtility.GetNuGetHomePluginsPath() }; -#if IS_DESKTOP - // Internal plugins are only supported for .NET Framework scenarios, namely msbuild.exe - directories.Add(PluginDiscoveryUtility.GetInternalPlugins()); + if (PathValidator.IsValidLocalPath(path) || PathValidator.IsValidUncPath(path)) + { + if (File.Exists(path)) + { + FileInfo fileInfo = new FileInfo(path); + if (fileInfo.Name.StartsWith("nuget-plugin-", StringComparison.CurrentCultureIgnoreCase)) + { + // A DotNet tool plugin + if (IsValidPluginFile(fileInfo)) + { + PluginFile pluginFile = new PluginFile(fileInfo.FullName, new Lazy(() => PluginFileState.Valid), isDotnetToolsPlugin: true); + pluginFiles.Add(pluginFile); + } + } + else + { + // A non DotNet tool plugin file + var state = new Lazy(() => PluginFileState.Valid); + pluginFiles.Add(new PluginFile(fileInfo.FullName, state)); + } + } + else if (Directory.Exists(path)) + { + pluginFiles.AddRange(GetNetToolsPluginsInDirectory(path) ?? new List()); + } + } + else + { + pluginFiles.Add(new PluginFile(path, new Lazy(() => PluginFileState.InvalidFilePath))); + } + } + + return pluginFiles; + } + + /// + /// Retrieves .NET tools authentication plugins by searching through directories specified in `PATH` + /// + /// A list of valid objects representing the discovered plugins. + internal List GetPluginsInPath() + { + var pluginFiles = new List(); + var nugetPluginPaths = _environmentVariableReader.GetEnvironmentVariable("PATH"); + string[] paths = nugetPluginPaths?.Split(Path.PathSeparator) ?? Array.Empty(); + + foreach (var path in paths) + { + if (PathValidator.IsValidLocalPath(path) || PathValidator.IsValidUncPath(path)) + { + pluginFiles.AddRange(GetNetToolsPluginsInDirectory(path) ?? new List()); + } + else + { + pluginFiles.Add(new PluginFile(path, new Lazy(() => PluginFileState.InvalidFilePath))); + } + } + + return pluginFiles; + } + + private static List GetNetToolsPluginsInDirectory(string directoryPath) + { + var pluginFiles = new List(); + + if (Directory.Exists(directoryPath)) + { + var directoryInfo = new DirectoryInfo(directoryPath); + var files = directoryInfo.GetFiles("nuget-plugin-*"); + + foreach (var file in files) + { + if (IsValidPluginFile(file)) + { + PluginFile pluginFile = new PluginFile(file.FullName, new Lazy(() => PluginFileState.Valid), isDotnetToolsPlugin: true); + pluginFiles.Add(pluginFile); + } + } + } + + return pluginFiles; + } + + /// + /// Checks whether a file is a valid plugin file for windows/Unix. + /// Windows: It should be either .bat or .exe + /// Unix: It should be executable + /// + /// + /// + internal static bool IsValidPluginFile(FileInfo fileInfo) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return fileInfo.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) || + fileInfo.Extension.Equals(".bat", StringComparison.OrdinalIgnoreCase); + } + else + { +#if NET8_0_OR_GREATER + var fileMode = File.GetUnixFileMode(fileInfo.FullName); + + return fileInfo.Exists && + ((fileMode & UnixFileMode.UserExecute) != 0 || + (fileMode & UnixFileMode.GroupExecute) != 0 || + (fileMode & UnixFileMode.OtherExecute) != 0); +#else + return fileInfo.Exists && IsExecutable(fileInfo); #endif - return PluginDiscoveryUtility.GetConventionBasedPlugins(directories); } + } + +#if !NET8_0_OR_GREATER + /// + /// Checks whether a file is executable or not in Unix. + /// This is done by running bash code: `if [ -x {fileInfo.FullName} ]; then echo yes; else echo no; fi` + /// + /// + /// + internal static bool IsExecutable(FileInfo fileInfo) + { +#pragma warning disable CA1031 // Do not catch general exception types + try + { + string output; + using (var process = new System.Diagnostics.Process()) + { + // Use a shell command to check if the file is executable + process.StartInfo.FileName = "/bin/bash"; + process.StartInfo.Arguments = $" -c \"if [ -x '{fileInfo.FullName}' ]; then echo yes; else echo no; fi\""; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + + process.Start(); + output = process.StandardOutput.ReadToEnd().Trim(); + + if (!process.HasExited && !process.WaitForExit(1000)) + { + process.Kill(); + return false; + } + else if (process.ExitCode != 0) + { + return false; + } - return _rawPluginPaths.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + // Check if the output is "yes" + return output.Equals("yes", StringComparison.OrdinalIgnoreCase); + } + } + catch + { + return false; + } +#pragma warning restore CA1031 // Do not catch general exception types } +#endif } } diff --git a/src/NuGet.Core/NuGet.Protocol/Plugins/PluginFile.cs b/src/NuGet.Core/NuGet.Protocol/Plugins/PluginFile.cs index 9753e0285bf..96c24f2c86f 100644 --- a/src/NuGet.Core/NuGet.Protocol/Plugins/PluginFile.cs +++ b/src/NuGet.Core/NuGet.Protocol/Plugins/PluginFile.cs @@ -20,6 +20,22 @@ public sealed class PluginFile /// public Lazy State { get; } + /// + /// Is the plugin file, a dotnet tools plugin file? + /// + internal bool IsDotnetToolsPlugin { get; } + + /// + /// Instantiates a new class. + /// + /// The plugin's file path. + /// A lazy that evaluates the plugin file state. + /// Is the plugin file, a dotnet tools plugin file? + internal PluginFile(string filePath, Lazy state, bool isDotnetToolsPlugin) : this(filePath, state) + { + IsDotnetToolsPlugin = isDotnetToolsPlugin; + } + /// /// Instantiates a new class. /// diff --git a/src/NuGet.Core/NuGet.Protocol/Plugins/PluginManager.cs b/src/NuGet.Core/NuGet.Protocol/Plugins/PluginManager.cs index 53dfa731769..6ff4407f862 100644 --- a/src/NuGet.Core/NuGet.Protocol/Plugins/PluginManager.cs +++ b/src/NuGet.Core/NuGet.Protocol/Plugins/PluginManager.cs @@ -33,7 +33,6 @@ public sealed class PluginManager : IPluginManager, IDisposable private IPluginFactory _pluginFactory; private ConcurrentDictionary>>> _pluginOperationClaims; private ConcurrentDictionary> _pluginUtilities; - private string _rawPluginPaths; private static Lazy _currentProcessId = new Lazy(GetCurrentProcessId); private Lazy _pluginsCacheDirectoryPath; @@ -312,15 +311,6 @@ private void Initialize(IEnvironmentVariableReader reader, { throw new ArgumentNullException(nameof(pluginFactoryCreator)); } -#if IS_DESKTOP - _rawPluginPaths = reader.GetEnvironmentVariable(EnvironmentVariableConstants.DesktopPluginPaths); -#else - _rawPluginPaths = reader.GetEnvironmentVariable(EnvironmentVariableConstants.CorePluginPaths); -#endif - if (string.IsNullOrEmpty(_rawPluginPaths)) - { - _rawPluginPaths = reader.GetEnvironmentVariable(EnvironmentVariableConstants.PluginPaths); - } _connectionOptions = ConnectionOptions.CreateDefault(reader); @@ -360,12 +350,20 @@ private async Task> GetPluginOperationClaimsAsync( private PluginDiscoverer InitializeDiscoverer() { - return new PluginDiscoverer(_rawPluginPaths); + return new PluginDiscoverer(); } private bool IsPluginPossiblyAvailable() { - return !string.IsNullOrEmpty(_rawPluginPaths); + 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) diff --git a/src/NuGet.Core/NuGet.Protocol/PublicAPI/net472/PublicAPI.Unshipped.txt b/src/NuGet.Core/NuGet.Protocol/PublicAPI/net472/PublicAPI.Unshipped.txt index 9f62b32fec7..7e98009cd58 100644 --- a/src/NuGet.Core/NuGet.Protocol/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/src/NuGet.Core/NuGet.Protocol/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,2 +1,2 @@ #nullable enable -~NuGet.Protocol.Plugins.PluginDiscoverer.PluginDiscoverer(string rawPluginPaths) -> void +NuGet.Protocol.Plugins.PluginDiscoverer.PluginDiscoverer() -> void diff --git a/src/NuGet.Core/NuGet.Protocol/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/NuGet.Core/NuGet.Protocol/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 9f62b32fec7..7e98009cd58 100644 --- a/src/NuGet.Core/NuGet.Protocol/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/src/NuGet.Core/NuGet.Protocol/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,2 +1,2 @@ #nullable enable -~NuGet.Protocol.Plugins.PluginDiscoverer.PluginDiscoverer(string rawPluginPaths) -> void +NuGet.Protocol.Plugins.PluginDiscoverer.PluginDiscoverer() -> void diff --git a/src/NuGet.Core/NuGet.Protocol/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/NuGet.Core/NuGet.Protocol/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 9f62b32fec7..7e98009cd58 100644 --- a/src/NuGet.Core/NuGet.Protocol/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/NuGet.Core/NuGet.Protocol/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,2 +1,2 @@ #nullable enable -~NuGet.Protocol.Plugins.PluginDiscoverer.PluginDiscoverer(string rawPluginPaths) -> void +NuGet.Protocol.Plugins.PluginDiscoverer.PluginDiscoverer() -> void diff --git a/test/NuGet.Core.Tests/NuGet.Protocol.Tests/Plugins/PluginDiscovererTests.cs b/test/NuGet.Core.Tests/NuGet.Protocol.Tests/Plugins/PluginDiscovererTests.cs index f48460dee65..f27749db149 100644 --- a/test/NuGet.Core.Tests/NuGet.Protocol.Tests/Plugins/PluginDiscovererTests.cs +++ b/test/NuGet.Core.Tests/NuGet.Protocol.Tests/Plugins/PluginDiscovererTests.cs @@ -3,10 +3,13 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Moq; +using NuGet.Common; using NuGet.Test.Utility; using Xunit; @@ -20,15 +23,27 @@ public class PluginDiscovererTests [InlineData(" ")] public void Constructor_AcceptsAnyString(string rawPluginPaths) { - using (new PluginDiscoverer(rawPluginPaths)) + var environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.DesktopPluginPaths)).Returns(rawPluginPaths); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.CorePluginPaths)).Returns(rawPluginPaths); + + Exception exception = Record.Exception(() => { - } + using (new PluginDiscoverer()) + { + } + }); + + Assert.Null(exception); } [Fact] public async Task DiscoverAsync_ThrowsIfCancelled() { - using (var discoverer = new PluginDiscoverer(rawPluginPaths: "")) + var environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.DesktopPluginPaths)).Returns(""); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.CorePluginPaths)).Returns(""); + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) { await Assert.ThrowsAsync( () => discoverer.DiscoverAsync(new CancellationToken(canceled: true))); @@ -38,7 +53,10 @@ await Assert.ThrowsAsync( [Fact] public async Task DiscoverAsync_DoesNotThrowIfNoValidFilePathsAndFallbackEmbeddedSignatureVerifier() { - using (var discoverer = new PluginDiscoverer(rawPluginPaths: ";")) + var environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.DesktopPluginPaths)).Returns(Path.PathSeparator.ToString()); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.CorePluginPaths)).Returns(Path.PathSeparator.ToString()); + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) { var pluginFiles = await discoverer.DiscoverAsync(CancellationToken.None); @@ -54,13 +72,11 @@ public async Task DiscoverAsync_PerformsDiscoveryOnlyOnce() var pluginPath = Path.Combine(testDirectory.Path, "a"); File.WriteAllText(pluginPath, string.Empty); + var environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.DesktopPluginPaths)).Returns(pluginPath); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.CorePluginPaths)).Returns(pluginPath); - var responses = new Dictionary() - { - { pluginPath, true } - }; - - using (var discoverer = new PluginDiscoverer(pluginPath)) + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) { var results = (await discoverer.DiscoverAsync(CancellationToken.None)).ToArray(); @@ -90,9 +106,11 @@ public async Task DiscoverAsync_HandlesAllPluginFileStates() File.WriteAllText(pluginPaths[1], string.Empty); string rawPluginPaths = - $"{pluginPaths[0]};{pluginPaths[1]};c"; - - using (var discoverer = new PluginDiscoverer(rawPluginPaths)) + $"{pluginPaths[0]}{Path.PathSeparator}{pluginPaths[1]}{Path.PathSeparator}c"; + var environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.DesktopPluginPaths)).Returns(rawPluginPaths); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.CorePluginPaths)).Returns(rawPluginPaths); + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) { var results = (await discoverer.DiscoverAsync(CancellationToken.None)).ToArray(); @@ -121,8 +139,10 @@ public async Task DiscoverAsync_HandlesAllPluginFileStates() public async Task DiscoverAsync_DisallowsNonRootedFilePaths(string pluginPath) { var responses = new Dictionary() { { pluginPath, true } }; - - using (var discoverer = new PluginDiscoverer(pluginPath)) + var environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.DesktopPluginPaths)).Returns(pluginPath); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.CorePluginPaths)).Returns(pluginPath); + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) { var results = (await discoverer.DiscoverAsync(CancellationToken.None)).ToArray(); @@ -140,8 +160,11 @@ public async Task DiscoverAsync_IsIdempotent() var pluginPath = Path.Combine(testDirectory.Path, "a"); File.WriteAllText(pluginPath, string.Empty); + var environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.DesktopPluginPaths)).Returns(pluginPath); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.CorePluginPaths)).Returns(pluginPath); - using (var discoverer = new PluginDiscoverer(pluginPath)) + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) { var firstResult = await discoverer.DiscoverAsync(CancellationToken.None); var firstState = firstResult.SingleOrDefault().PluginFile.State.Value; @@ -153,5 +176,544 @@ public async Task DiscoverAsync_IsIdempotent() } } } + + [PlatformTheory(Platform.Windows)] + [InlineData("nuget-plugin-myPlugin.exe")] + [InlineData("nuget-plugin-myPlugin.bat")] + public async Task DiscoverAsync_withValidDotNetToolsPluginWindows_FindsThePlugin(string fileName) + { + using (var testDirectory = TestDirectory.Create()) + { + // Arrange + var pluginPath = Path.Combine(testDirectory.Path, "myPlugin"); + Directory.CreateDirectory(pluginPath); + var myPlugin = Path.Combine(pluginPath, fileName); + Mock environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.PluginPaths)).Returns(pluginPath); + + File.WriteAllText(myPlugin, string.Empty); + + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) + { + // Act + var result = await discoverer.DiscoverAsync(CancellationToken.None); + + // Assert + var discovered = false; + + foreach (PluginDiscoveryResult discoveryResult in result) + { + if (myPlugin == discoveryResult.PluginFile.Path) discovered = true; + } + + Assert.True(discovered); + } + } + } + + [PlatformTheory(Platform.Windows)] + [InlineData("nuget-plugin-myPlugin.exe")] + [InlineData("nuget-plugin-myPlugin.bat")] + public async Task DiscoverAsync_WithPluginPathSpecifiedInNuGetPluginPathsEnvVariableWindows_FindsThePlugin(string fileName) + { + using (var testDirectory = TestDirectory.Create()) + { + // Arrange + var pluginPath = Path.Combine(testDirectory.Path, "myPlugin"); + Directory.CreateDirectory(pluginPath); + var myPlugin = Path.Combine(pluginPath, fileName); + Mock environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable("NUGET_PLUGIN_PATHS")).Returns(pluginPath); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable("PATHS")).Returns(""); + File.WriteAllText(myPlugin, string.Empty); + + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) + { + // Act + var result = await discoverer.DiscoverAsync(CancellationToken.None); + + // Assert + var discovered = false; + + foreach (PluginDiscoveryResult discoveryResult in result) + { + if (myPlugin == discoveryResult.PluginFile.Path) discovered = true; + } + + Assert.True(discovered); + } + } + } + + [PlatformTheory(Platform.Windows)] + [InlineData("nugetplugin-myPlugin.exe")] + [InlineData("nugetplugin-myPlugin.bat")] + public async Task DiscoverAsync_withInValidDotNetToolsPluginNameWindows_DoesNotFindThePlugin(string fileName) + { + using (var testDirectory = TestDirectory.Create()) + { + // Arrange + var pluginPath = Path.Combine(testDirectory.Path, "myPlugin"); + Directory.CreateDirectory(pluginPath); + var myPlugin = Path.Combine(pluginPath, fileName); + Mock environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(It.IsAny())).Returns(pluginPath); + + File.WriteAllText(myPlugin, string.Empty); + + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) + { + // Act + var result = await discoverer.DiscoverAsync(CancellationToken.None); + + // Assert + var discovered = false; + + foreach (PluginDiscoveryResult discoveryResult in result) + { + if (myPlugin == discoveryResult.PluginFile.Path) discovered = true; + } + + Assert.False(discovered); + } + } + } + + [PlatformFact(Platform.Linux)] + public async Task DiscoverAsync_withValidDotNetToolsPluginLinux_FindsThePlugin() + { + using (var testDirectory = TestDirectory.Create()) + { + // Arrange + var pluginPath = Path.Combine(testDirectory.Path, "myPlugins"); + Directory.CreateDirectory(pluginPath); + var myPlugin = Path.Combine(pluginPath, "nuget-plugin-MyPlugin"); + File.WriteAllText(myPlugin, string.Empty); + Mock environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.PluginPaths)).Returns(pluginPath); + + using (var process = new Process()) + { + // Use a shell command to make the file executable + process.StartInfo.FileName = "/bin/bash"; + process.StartInfo.Arguments = $"-c \"chmod +x {myPlugin}\""; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.Start(); + process.WaitForExit(); + + if (process.ExitCode == 0) + { + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) + { + // Act + var result = await discoverer.DiscoverAsync(CancellationToken.None); + + // Assert + var discovered = false; + + foreach (PluginDiscoveryResult discoveryResult in result) + { + if (myPlugin == discoveryResult.PluginFile.Path) discovered = true; + } + + Assert.True(discovered); + } + } + } + } + } + + [PlatformFact(Platform.Linux)] + public async Task DiscoverAsync_WithPluginPathSpecifiedInNuGetPluginPathsEnvVariableLinux_FindsThePlugin() + { + using (var testDirectory = TestDirectory.Create()) + { + // Arrange + var pluginPath = Path.Combine(testDirectory.Path, "myPlugins"); + Directory.CreateDirectory(pluginPath); + var myPlugin = Path.Combine(pluginPath, "nuget-plugin-MyPlugin"); + File.WriteAllText(myPlugin, string.Empty); + Mock environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.PluginPaths)).Returns(pluginPath); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable("PATHS")).Returns(""); + + using (var process = new Process()) + { + // Use a shell command to make the file executable + process.StartInfo.FileName = "/bin/bash"; + process.StartInfo.Arguments = $"-c \"chmod +x {myPlugin}\""; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.Start(); + process.WaitForExit(); + + if (process.ExitCode == 0) + { + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) + { + // Act + var result = await discoverer.DiscoverAsync(CancellationToken.None); + + // Assert + var discovered = false; + + foreach (PluginDiscoveryResult discoveryResult in result) + { + if (myPlugin == discoveryResult.PluginFile.Path) discovered = true; + } + + Assert.True(discovered); + } + } + } + } + } + + [PlatformFact(Platform.Linux)] + public async Task DiscoverAsync_withNoExecutableValidDotNetToolsPluginLinux_DoesNotFindThePlugin() + { + using (var testDirectory = TestDirectory.Create()) + { + // Arrange + var pluginPath = Path.Combine(testDirectory.Path, "myPlugins"); + Directory.CreateDirectory(pluginPath); + var myPlugin = Path.Combine(pluginPath, "nuget-plugin-MyPlugin"); + File.WriteAllText(myPlugin, string.Empty); + Mock environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.PluginPaths)).Returns(pluginPath); + + using (var process = new Process()) + { + // Use a shell command to make the file not executable + process.StartInfo.FileName = "/bin/bash"; + process.StartInfo.Arguments = $"-c \"chmod -x {myPlugin}\""; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.Start(); + process.WaitForExit(); + + if (process.ExitCode == 0) + { + using (var discoverer = new PluginDiscoverer(environmentalVariableReader.Object)) + { + // Act + var result = await discoverer.DiscoverAsync(CancellationToken.None); + + // Assert + var discovered = false; + + foreach (PluginDiscoveryResult discoveryResult in result) + { + if (myPlugin == discoveryResult.PluginFile.Path) discovered = true; + } + + Assert.False(discovered); + } + } + } + } + } + + [PlatformFact(Platform.Windows)] + public void GetPluginsInNuGetPluginPaths_WithNuGetPluginPathsSet_ReturnsPluginsInNuGetPluginPathOnly() + { + // Arrange + using TestDirectory pluginPathDirectory = TestDirectory.Create(); + using TestDirectory pathDirectory = TestDirectory.Create(); + var pluginInNuGetPluginPathDirectoryFilePath = Path.Combine(pluginPathDirectory.Path, "nuget-plugin-auth.exe"); + var pluginInPathDirectoryFilePath = Path.Combine(pathDirectory.Path, "nuget-plugin-in-path-directory.exe"); + File.Create(pluginInNuGetPluginPathDirectoryFilePath); + File.Create(pluginInPathDirectoryFilePath); + Mock environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.PluginPaths)).Returns(Directory.GetParent(pluginInNuGetPluginPathDirectoryFilePath).FullName); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable("PATH")).Returns(Directory.GetParent(pluginInPathDirectoryFilePath).FullName); + PluginDiscoverer pluginDiscoverer = new PluginDiscoverer(environmentalVariableReader.Object); + + // Act + var plugins = pluginDiscoverer.GetPluginsInNuGetPluginPaths(); + + // Assert + Assert.Single(plugins); + Assert.Equal(pluginInNuGetPluginPathDirectoryFilePath, plugins[0].Path); + Assert.True(plugins[0].IsDotnetToolsPlugin); + } + + [PlatformFact(Platform.Windows)] + public void GetPluginsInNuGetPluginPaths_WithoutNuGetPluginPaths_ReturnsEmpty() + { + // Arrange + using var pathDirectory = TestDirectory.Create(); + var pluginFilePath = Path.Combine(pathDirectory.Path, "nuget-plugin-fallback.exe"); + File.Create(pluginFilePath); + + var environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable("PATH")).Returns(pathDirectory.Path); + + var pluginDiscoverer = new PluginDiscoverer(environmentalVariableReader.Object); + + // Act + var plugins = pluginDiscoverer.GetPluginsInNuGetPluginPaths(); + + // Assert + Assert.Empty(plugins); + } + + [PlatformFact(Platform.Windows)] + public void GetPluginsInPATH_WithPATHSet_ReturnsPlugin() + { + // Arrange + using TestDirectory pluginPathDirectory = TestDirectory.Create(); + using TestDirectory pathDirectory = TestDirectory.Create(); + var pluginInNuGetPluginPathDirectoryFilePath = Path.Combine(pluginPathDirectory.Path, "nuget-plugin-auth.exe"); + var pluginInPathDirectoryFilePath = Path.Combine(pathDirectory.Path, "nuget-plugin-in-path-directory.exe"); + File.Create(pluginInNuGetPluginPathDirectoryFilePath); + File.Create(pluginInPathDirectoryFilePath); + Mock environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.PluginPaths)).Returns(Directory.GetParent(pluginInNuGetPluginPathDirectoryFilePath).FullName); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable("PATH")).Returns(Directory.GetParent(pluginInPathDirectoryFilePath).FullName); + PluginDiscoverer pluginDiscoverer = new PluginDiscoverer(environmentalVariableReader.Object); + + // Act + var plugins = pluginDiscoverer.GetPluginsInPath(); + + // Assert + Assert.Single(plugins); + Assert.Equal(pluginInPathDirectoryFilePath, plugins[0].Path); + Assert.True(plugins[0].IsDotnetToolsPlugin); + } + + [PlatformFact(Platform.Windows)] + public void GetPluginsInNuGetPluginPaths_NuGetPluginPathsPointsToAFile_TreatsAsPlugin() + { + // Arrange + using TestDirectory testDirectory = TestDirectory.Create(); + var pluginFilePath = Path.Combine(testDirectory.Path, "nuget-plugin-auth.exe"); + File.Create(pluginFilePath); + + var environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.PluginPaths)).Returns(pluginFilePath); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable("PATH")).Returns(string.Empty); + + var pluginDiscoverer = new PluginDiscoverer(environmentalVariableReader.Object); + + // Act + var plugins = pluginDiscoverer.GetPluginsInNuGetPluginPaths(); + + // Assert + Assert.Single(plugins); + Assert.Equal(pluginFilePath, plugins[0].Path); + Assert.True(plugins[0].IsDotnetToolsPlugin); + } + + [PlatformFact(Platform.Windows)] + public void GetPluginsInNuGetPluginPaths_NuGetPluginPathsPointsToAFileThatDoesNotStartWithNugetPlugin_ReturnsNonDotnetPlugin() + { + // Arrange + using TestDirectory testDirectory = TestDirectory.Create(); + var pluginFilePath = Path.Combine(testDirectory.Path, "other-plugin.exe"); + File.Create(pluginFilePath); + + var environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable(EnvironmentVariableConstants.PluginPaths)).Returns(pluginFilePath); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable("PATH")).Returns(string.Empty); + + var pluginDiscoverer = new PluginDiscoverer(environmentalVariableReader.Object); + + // Act + var plugins = pluginDiscoverer.GetPluginsInNuGetPluginPaths(); + + // Assert + Assert.Single(plugins); + Assert.False(plugins[0].IsDotnetToolsPlugin); + } + + [PlatformFact(Platform.Windows)] + public void GetPluginsInPATH_PATHPointsToADirectory_ContainsValidPluginFiles() + { + // Arrange + using var pluginPathDirectory = TestDirectory.Create(); + var validPluginFile = Path.Combine(pluginPathDirectory.Path, "nuget-plugin-auth.exe"); + var invalidPluginFile = Path.Combine(pluginPathDirectory.Path, "not-a-nuget-plugin.exe"); + File.Create(validPluginFile); + File.Create(invalidPluginFile); + + var environmentalVariableReader = new Mock(); + environmentalVariableReader.Setup(env => env.GetEnvironmentVariable("PATH")).Returns(pluginPathDirectory.Path); + + var pluginDiscoverer = new PluginDiscoverer(environmentalVariableReader.Object); + + // Act + var plugins = pluginDiscoverer.GetPluginsInPath(); + + // Assert + Assert.Single(plugins); + Assert.Equal(validPluginFile, plugins[0].Path); + Assert.True(plugins[0].IsDotnetToolsPlugin); + } + + [PlatformFact(Platform.Windows)] + public void GetPluginsInNuGetPluginPaths_NoEnvironmentVariables_ReturnsNoPlugins() + { + // Arrange + var environmentalVariableReader = new Mock(); + var pluginDiscoverer = new PluginDiscoverer(environmentalVariableReader.Object); + + // Act + var plugins = pluginDiscoverer.GetPluginsInNuGetPluginPaths(); + + // Assert + Assert.Empty(plugins); + } + + [PlatformFact(Platform.Windows)] + public void IsValidPluginFile_ExeFile_ReturnsTrue() + { + // Arrange + using TestDirectory testDirectory = TestDirectory.Create(); + var workingPath = testDirectory.Path; + var pluginFilePath = Path.Combine(workingPath, "plugin.exe"); + File.Create(pluginFilePath); + var fileInfo = new FileInfo(pluginFilePath); + + // Act + bool result = PluginDiscoverer.IsValidPluginFile(fileInfo); + + // Assert + Assert.True(result); + } + + [PlatformFact(Platform.Windows)] + public void IsValidPluginFile_Windows_NonExecutableFile_ReturnsFalse() + { + // Arrange + using TestDirectory testDirectory = TestDirectory.Create(); + var workingPath = testDirectory.Path; + var nonPluginFilePath = Path.Combine(workingPath, "plugin.txt"); + File.Create(nonPluginFilePath); + var fileInfo = new FileInfo(nonPluginFilePath); + + // Act + bool result = PluginDiscoverer.IsValidPluginFile(fileInfo); + + // Assert + Assert.False(result); + } + + [PlatformFact(Platform.Linux)] + public void IsValidPluginFile_Unix_ExecutableFile_ReturnsTrue() + { + // Arrange + using TestDirectory testDirectory = TestDirectory.Create(); + var workingPath = testDirectory.Path; + var pluginFilePath = Path.Combine(workingPath, "plugin"); + File.Create(pluginFilePath).Dispose(); + +#if NET8_0_OR_GREATER + // Set execute permissions + File.SetUnixFileMode(pluginFilePath, UnixFileMode.UserExecute | UnixFileMode.UserRead); +#else + // Use chmod to set execute permissions + var process = new Process(); + process.StartInfo.FileName = "/bin/bash"; + process.StartInfo.Arguments = $"-c \"chmod +x {pluginFilePath}\""; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.Start(); + process.WaitForExit(); +#endif + + var fileInfo = new FileInfo(pluginFilePath); + + // Act + bool result = PluginDiscoverer.IsValidPluginFile(fileInfo); + + // Assert + Assert.True(result); + } + +#if !NET8_0_OR_GREATER + [PlatformFact(Platform.Linux)] + public void IsExecutable_FileIsExecutable_ReturnsTrue() + { + // Arrange + using TestDirectory testDirectory = TestDirectory.Create(); + var workingPath = testDirectory.Path; + var pluginFilePath = Path.Combine(workingPath, "plugin"); + File.Create(pluginFilePath); + + // Set execute permissions + var process = new Process(); + process.StartInfo.FileName = "/bin/bash"; + process.StartInfo.Arguments = $"-c \"chmod +x {pluginFilePath}\""; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.Start(); + process.WaitForExit(); + + var fileInfo = new FileInfo(pluginFilePath); + + // Act + bool result = PluginDiscoverer.IsExecutable(fileInfo); + + // Assert + Assert.True(result); + } + + [PlatformFact(Platform.Linux)] + public void IsExecutable_FileIsNotExecutable_ReturnsFalse() + { + // Arrange + using TestDirectory testDirectory = TestDirectory.Create(); + var workingPath = testDirectory.Path; + var pluginFilePath = Path.Combine(workingPath, "plugin"); + File.Create(pluginFilePath); + + // Remove execute permissions + var process = new Process(); + process.StartInfo.FileName = "/bin/bash"; + process.StartInfo.Arguments = $"-c \"chmod -x {pluginFilePath}\""; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.Start(); + process.WaitForExit(); + + var fileInfo = new FileInfo(pluginFilePath); + + // Act + bool result = PluginDiscoverer.IsExecutable(fileInfo); + + // Assert + Assert.False(result); + } + + [PlatformFact(Platform.Linux)] + public void IsExecutable_FileWithSpace_ReturnsTrue() + { + // Arrange + using TestDirectory testDirectory = TestDirectory.Create(); + var workingPath = testDirectory.Path; + var pluginFilePath = Path.Combine(workingPath, "plugin with space"); + File.Create(pluginFilePath).Dispose(); + + // Set execute permissions + var process = new Process(); + process.StartInfo.FileName = "/bin/bash"; + process.StartInfo.Arguments = $"-c \"chmod +x '{pluginFilePath}'\""; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.Start(); + process.WaitForExit(); + + var fileInfo = new FileInfo(pluginFilePath); + + // Act + bool result = PluginDiscoverer.IsExecutable(fileInfo); + + // Assert + Assert.True(result); + } + +#endif } }