-
Notifications
You must be signed in to change notification settings - Fork 742
[Feature] Implement Support for NuGet Authentication Plugins as .NET Tools #6138
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6c1f792
d883a09
9d3acc2
1714493
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,42 @@ public sealed class PluginDiscoverer : IPluginDiscoverer | |
| { | ||
| private bool _isDisposed; | ||
| private List<PluginFile> _pluginFiles; | ||
| private readonly string _rawPluginPaths; | ||
| private readonly string _netCoreOrNetFXPluginPaths; | ||
| private readonly string _nuGetPluginPaths; | ||
| private IEnumerable<PluginDiscoveryResult> _results; | ||
| private readonly SemaphoreSlim _semaphore; | ||
| private readonly IEnvironmentVariableReader _environmentVariableReader; | ||
| private static bool IsDesktop | ||
| { | ||
| get | ||
| { | ||
| #if IS_DESKTOP | ||
| return true; | ||
| #else | ||
| return false; | ||
| #endif | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Instantiates a new <see cref="PluginDiscoverer" /> class. | ||
| /// </summary> | ||
| /// <param name="rawPluginPaths">The raw semicolon-delimited list of supposed plugin file paths.</param> | ||
| public PluginDiscoverer(string rawPluginPaths) | ||
| public PluginDiscoverer() | ||
| : this(EnvironmentVariableWrapper.Instance) | ||
| { | ||
| } | ||
|
|
||
| internal PluginDiscoverer(IEnvironmentVariableReader environmentVariableReader) | ||
| { | ||
| _rawPluginPaths = rawPluginPaths; | ||
| _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 +101,40 @@ public async Task<IEnumerable<PluginDiscoveryResult>> 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<string> { 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<PluginDiscoveryResult>(); | ||
|
|
||
| for (var i = 0; i < _pluginFiles.Count; ++i) | ||
|
|
@@ -97,14 +156,17 @@ public async Task<IEnumerable<PluginDiscoveryResult>> DiscoverAsync(Cancellation | |
| return _results; | ||
| } | ||
|
|
||
| private List<PluginFile> GetPluginFiles(CancellationToken cancellationToken) | ||
| private static List<PluginFile> GetPluginFiles(IEnumerable<string> filePaths, CancellationToken cancellationToken) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
|
|
||
| var filePaths = GetPluginFilePaths(); | ||
|
|
||
| var files = new List<PluginFile>(); | ||
|
|
||
| if (filePaths == null) | ||
| { | ||
| return files; | ||
| } | ||
|
|
||
| foreach (var filePath in filePaths) | ||
| { | ||
| var pluginFile = new PluginFile(filePath, new Lazy<PluginFileState>(() => | ||
|
|
@@ -117,26 +179,180 @@ private List<PluginFile> GetPluginFiles(CancellationToken cancellationToken) | |
| { | ||
| return PluginFileState.InvalidFilePath; | ||
| } | ||
| })); | ||
| }), requiresDotnetHost: !IsDesktop); | ||
| files.Add(pluginFile); | ||
| } | ||
|
|
||
| return files; | ||
| } | ||
|
|
||
| private IEnumerable<string> GetPluginFilePaths() | ||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| /// <returns>A list of valid <see cref="PluginFile"/> objects representing the discovered plugins.</returns> | ||
| internal List<PluginFile> GetPluginsInNuGetPluginPaths() | ||
| { | ||
| if (string.IsNullOrEmpty(_rawPluginPaths)) | ||
| var pluginFiles = new List<PluginFile>(); | ||
| string[] paths = _nuGetPluginPaths?.Split(Path.PathSeparator) ?? Array.Empty<string>(); | ||
|
|
||
| foreach (var path in paths) | ||
| { | ||
| var directories = new List<string> { 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>(() => PluginFileState.Valid), requiresDotnetHost: false); | ||
| pluginFiles.Add(pluginFile); | ||
| } | ||
| } | ||
| else | ||
| { | ||
| // A non DotNet tool plugin file | ||
| var state = new Lazy<PluginFileState>(() => PluginFileState.Valid); | ||
| pluginFiles.Add(new PluginFile(fileInfo.FullName, state, requiresDotnetHost: !IsDesktop)); | ||
| } | ||
| } | ||
| else if (Directory.Exists(path)) | ||
| { | ||
| pluginFiles.AddRange(GetNetToolsPluginsInDirectory(path) ?? new List<PluginFile>()); | ||
| } | ||
| } | ||
| else | ||
| { | ||
| pluginFiles.Add(new PluginFile(path, new Lazy<PluginFileState>(() => PluginFileState.InvalidFilePath), requiresDotnetHost: !IsDesktop)); | ||
| } | ||
| } | ||
|
|
||
| return pluginFiles; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Retrieves .NET tools authentication plugins by searching through directories specified in `PATH` | ||
| /// </summary> | ||
| /// <returns>A list of valid <see cref="PluginFile"/> objects representing the discovered plugins.</returns> | ||
| internal List<PluginFile> GetPluginsInPath() | ||
| { | ||
| var pluginFiles = new List<PluginFile>(); | ||
| var nugetPluginPaths = _environmentVariableReader.GetEnvironmentVariable("PATH"); | ||
| string[] paths = nugetPluginPaths?.Split(Path.PathSeparator) ?? Array.Empty<string>(); | ||
|
|
||
| foreach (var path in paths) | ||
| { | ||
| if (PathValidator.IsValidLocalPath(path) || PathValidator.IsValidUncPath(path)) | ||
| { | ||
| pluginFiles.AddRange(GetNetToolsPluginsInDirectory(path) ?? new List<PluginFile>()); | ||
| } | ||
| else | ||
| { | ||
| pluginFiles.Add(new PluginFile(path, new Lazy<PluginFileState>(() => PluginFileState.InvalidFilePath), requiresDotnetHost: false)); | ||
| } | ||
| } | ||
|
|
||
| return pluginFiles; | ||
| } | ||
|
|
||
| private static List<PluginFile> GetNetToolsPluginsInDirectory(string directoryPath) | ||
| { | ||
| var pluginFiles = new List<PluginFile>(); | ||
|
|
||
| if (Directory.Exists(directoryPath)) | ||
| { | ||
| var directoryInfo = new DirectoryInfo(directoryPath); | ||
| var files = directoryInfo.GetFiles("nuget-plugin-*"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe the case sensitivity of this match will be platform-dependent, meaning on Windows it will be commonly (not guaranteed) case insensitive, but on Linux it will be case sensitive. However, the file name comparison here is always case-insensitive. Can you explain your reasoning?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right, that's inconsistent. I will address this in a follow up PR.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Nigusu-Allehu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| foreach (var file in files) | ||
| { | ||
| if (IsValidPluginFile(file)) | ||
| { | ||
| PluginFile pluginFile = new PluginFile(file.FullName, new Lazy<PluginFileState>(() => PluginFileState.Valid), requiresDotnetHost: false); | ||
| pluginFiles.Add(pluginFile); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return pluginFiles; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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 | ||
| /// </summary> | ||
| /// <param name="fileInfo"></param> | ||
| /// <returns></returns> | ||
| 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); | ||
| } | ||
| } | ||
|
|
||
| return _rawPluginPaths.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); | ||
| #if !NET8_0_OR_GREATER | ||
| /// <summary> | ||
| /// 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` | ||
| /// </summary> | ||
| /// <param name="fileInfo"></param> | ||
| /// <returns></returns> | ||
| 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"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code could run on Alpine Linux, where /bin/bash does not exist by default. Is there an even more portable way of checking? I don't know, but maybe
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On unix systems, $ dotnet fsi
Microsoft (R) F# Interactive version 12.8.102.0 for F# 8.0
Copyright (c) Microsoft Corporation. All Rights Reserved.
For help type #help;;
> open System.IO;;
> let f = new FileInfo("/usr/bin/curl");;
val f: FileInfo = /usr/bin/curl
> f.UnixFileMode;;
val it: UnixFileMode =
OtherExecute, OtherRead, GroupExecute, GroupRead, UserExecute, UserWrite, UserRead
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code is in a I also suggested to @Nigusu-Allehu that we could consider skipping this PATH scanning for plugins for .NET Framework on Linux and Mac, since that only means Mono, which we don't officially support, but I don't remember any replies to that comment, and clearly it wasn't actioned either. But I think it's also ok to keep here, because I really can't imagine anyone using Alpine wanting to install Mono and use NuGet.exe. And even if they do, the whole thing is wrapped in a try-catch block. The plugin won't be discovered, but it won't break restore either (apart from not being able to authenticate if a nuget-plugin-* app is installed) |
||
| 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; | ||
| } | ||
|
|
||
| // 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 | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you explain this change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We had to update the API for IPluginFactory so that it is able to create plugins for dotnet tools plugins. Since we needed to break the API, I used it as an opportunity to un-expose the interface. The reason for making it internal is that the interface only has one implementation and there is no need for it to be a public API #6113 (comment)