#pragma warning disable SA1512,SA1124 // Single-line comments should not be followed by blank line
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using JetBrains.Annotations;
using UnityEngine;
using Debug = UnityEngine.Debug;
#region No ReShaper
// ReSharper disable All
// needed because 'JetBrains.Annotations.NotNull' and 'System.Diagnostics.CodeAnalysis.NotNull' collide if this file is compiled with a never version of Unity / C#
using SuppressMessageAttribute = System.Diagnostics.CodeAnalysis.SuppressMessageAttribute;
// ReSharper restore All
#endregion
#pragma warning restore SA1512,SA1124 // Single-line comments should not be followed by blank line
namespace NugetForUnity.Helper
{
///
/// Helper class for retrieving credentials from NuGet credential providers.
///
internal static class CredentialProviderHelper
{
// TODO: Move to settings
private static readonly List KnownAuthenticatedFeeds = new List
{
new AuthenticatedFeed(
@"^https:\/\/(?[-a-zA-Z0-9]+)\.pkgs\.visualstudio\.com",
"https://{account}.pkgs.visualstudio.com/_apis/public/nuget/client/CredentialProviderBundle.zip"),
new AuthenticatedFeed(
@"^https:\/\/pkgs\.dev\.azure\.com\/(?[-a-zA-Z0-9]+)\/",
"https://pkgs.dev.azure.com/{account}/_apis/public/nuget/client/CredentialProviderBundle.zip"),
};
///
/// The dictionary of cached credentials retrieved by credential providers, keyed by feed URI.
///
private static readonly Dictionary CachedCredentialsByFeedUri =
new Dictionary();
///
/// Helper function to acquired a token to access VSTS hosted NuGet feeds by using the CredentialProvider.VSS.exe
/// tool. Downloading it from the VSTS instance if needed.
/// See here for more info on NuGet Credential Providers:
/// https://docs.microsoft.com/en-us/nuget/reference/extensibility/nuget-exe-credential-providers.
///
/// The url where the VSTS instance is hosted, the HostName is extracted (such as microsoft.pkgs.visualsudio.com).
/// The password in the form of a token, or null if the password could not be acquired.
public static (string UserName, string Password)? GetCredentialFromProvider([NotNull] Uri feedUri)
{
feedUri = GetTruncatedFeedUri(feedUri);
if (!CachedCredentialsByFeedUri.TryGetValue(feedUri, out var response))
{
response = GetCredentialFromProvider_Uncached(feedUri, true);
CachedCredentialsByFeedUri[feedUri] = response;
}
if (response == null)
{
return null;
}
return (response.Value.Username, response.Value.Password);
}
///
/// Clears static credentials previously cached by GetCredentialFromProvider.
///
public static void ClearCachedCredentials()
{
CachedCredentialsByFeedUri.Clear();
}
///
/// Given the URI of a NuGet method, returns the URI of the feed itself without the method and query parameters.
///
/// URI of NuGet method.
/// URI of the feed without the method and query parameters.
[NotNull]
private static Uri GetTruncatedFeedUri([NotNull] Uri methodUri)
{
var truncatedUriString = methodUri.GetLeftPart(UriPartial.Path);
// Pull off the function if there is one
if (truncatedUriString.EndsWith(")", StringComparison.Ordinal))
{
var lastSeparatorIndex = truncatedUriString.LastIndexOf('/');
if (lastSeparatorIndex != -1)
{
truncatedUriString = truncatedUriString.Substring(0, lastSeparatorIndex);
}
}
var truncatedUri = new Uri(truncatedUriString);
return truncatedUri;
}
///
/// Internal function called by GetCredentialFromProvider to implement retrieving credentials. For performance reasons,
/// most functions should call GetCredentialFromProvider in order to take advantage of cached credentials.
///
private static CredentialProviderResponse? GetCredentialFromProvider_Uncached([NotNull] Uri feedUri, bool downloadIfMissing)
{
NugetLogger.LogVerbose("Getting credential for {0}", feedUri);
// Build the list of possible locations to find the credential provider. In order it should be local APP-Data, paths set on the
// environment variable, and lastly look at the root of the packages save location.
var possibleCredentialProviderPaths = new List { GetDefaultCredentialProvidersPath() };
possibleCredentialProviderPaths.AddRange(GetEnvironmentCredentialProviderPaths());
// Try to find any nuget.exe in the package tools installation location
var toolsPackagesFolder = Path.Combine(UnityPathHelper.AbsoluteProjectPath, "Packages");
possibleCredentialProviderPaths.Add(toolsPackagesFolder);
// Search through all possible paths to find the credential provider.
var providerPaths = new List();
foreach (var possiblePath in possibleCredentialProviderPaths.Distinct())
{
if (Directory.Exists(possiblePath))
{
providerPaths.AddRange(Directory.GetFiles(possiblePath, "credentialprovider*.exe", SearchOption.AllDirectories));
}
}
foreach (var providerPath in providerPaths.Distinct())
{
// Launch the credential provider executable and get the JSON encoded response from the std output
using (var process = new Process())
{
process.StartInfo = NugetCliHelper.CreateStartInfoForDotNetExecutable(providerPath, $"-uri \"{feedUri}\"");
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.Start();
process.WaitForExit();
var output = process.StandardOutput.ReadToEnd();
var errors = process.StandardError.ReadToEnd();
switch ((CredentialProviderExitCode)process.ExitCode)
{
case CredentialProviderExitCode.ProviderNotApplicable:
break; // Not the right provider
case CredentialProviderExitCode.Failure: // Right provider, failure to get credentials
Debug.LogErrorFormat(
"Failed to get credentials from {0}!\n\tOutput\n\t{1}\n\tErrors\n\t{2}",
providerPath,
output,
errors);
return null;
case CredentialProviderExitCode.Success:
return JsonUtility.FromJson(output);
default:
Debug.LogWarningFormat(
"Unrecognized exit code {0} from {1} {2}",
process.ExitCode,
providerPath,
process.StartInfo.Arguments);
break;
}
}
}
if (downloadIfMissing && DownloadCredentialProviders(feedUri))
{
return GetCredentialFromProvider_Uncached(feedUri, false);
}
return null;
}
private static IEnumerable GetEnvironmentCredentialProviderPaths()
{
var environmentCredentialProviderPaths = Environment.GetEnvironmentVariable("NUGET_CREDENTIALPROVIDERS_PATH");
if (!string.IsNullOrEmpty(environmentCredentialProviderPaths))
{
return environmentCredentialProviderPaths.Split(new[] { Path.PathSeparator, ';' }, StringSplitOptions.RemoveEmptyEntries);
}
return Array.Empty();
}
private static string GetDefaultCredentialProvidersPath()
{
var baseDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrEmpty(baseDirectory))
{
// we need a place to store the credential provider so we fallback to the temp location.
baseDirectory = Path.GetTempPath();
}
return Path.Combine(baseDirectory, "Nuget", "CredentialProviders");
}
private static bool DownloadCredentialProviders([NotNull] Uri feedUri)
{
var anyDownloaded = false;
foreach (var feed in KnownAuthenticatedFeeds)
{
var account = feed.GetAccount(feedUri.ToString());
if (string.IsNullOrEmpty(account))
{
continue;
}
var providerUrl = feed.GetProviderUrl(account);
#pragma warning disable IDE0079 // Remove unnecessary suppression
#pragma warning disable SYSLIB0014 // Type or member is obsolete
var credentialProviderRequest = (HttpWebRequest)WebRequest.Create(providerUrl);
#pragma warning restore SYSLIB0014 // Type or member is obsolete
#pragma warning restore IDE0079 // Remove unnecessary suppression
var tempFileName = Path.GetTempFileName();
try
{
var credentialProviderDownloadStream = credentialProviderRequest.GetResponse().GetResponseStream() ??
throw new InvalidOperationException("Response stream is null.");
NugetLogger.LogVerbose("Writing {0} to {1}", providerUrl, tempFileName);
using (var file = File.Create(tempFileName))
{
credentialProviderDownloadStream.CopyTo(file);
}
var providerDestination = GetEnvironmentCredentialProviderPaths().FirstOrDefault();
if (string.IsNullOrEmpty(providerDestination))
{
providerDestination = GetDefaultCredentialProvidersPath();
}
// Normalizes the path.
providerDestination = Path.GetFullPath(providerDestination);
// Ensures that the last character on the extraction path is the directory separator char.
if (!providerDestination.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal))
{
providerDestination += Path.DirectorySeparatorChar;
}
// Unzip the bundle and extract any credential provider exes
using (var zip = ZipFile.OpenRead(tempFileName))
{
foreach (var entry in zip.Entries)
{
// TODO: probably we should extract all files
if (!Regex.IsMatch(entry.FullName, @"^credentialprovider.+\.exe$", RegexOptions.IgnoreCase))
{
continue;
}
// Gets the full path to ensure that relative segments are removed.
var filePath = Path.GetFullPath(Path.Combine(providerDestination, entry.FullName));
if (!filePath.StartsWith(providerDestination, StringComparison.Ordinal))
{
// disallow leaving destination path
continue;
}
NugetLogger.LogVerbose("Extracting {0} to {1}", entry.FullName, providerDestination);
var directory = Path.GetDirectoryName(filePath);
Directory.CreateDirectory(directory ?? throw new InvalidOperationException("Path has no directory name."));
entry.ExtractToFile(filePath, true);
anyDownloaded = true;
}
}
}
catch (Exception e)
{
Debug.LogErrorFormat("Failed to download credential provider from {0}: {1}", credentialProviderRequest.Address, e.Message);
}
finally
{
try
{
// Delete the bundle
File.Delete(tempFileName);
}
catch
{
// ignore error while deleting temp file
}
}
}
return anyDownloaded;
}
///
/// Possible response codes returned by a Nuget credential provider as described here:
/// https://docs.microsoft.com/en-us/nuget/reference/extensibility/nuget-exe-credential-providers#creating-a-nugetexe-credential-provider.
///
[SuppressMessage(
"StyleCop.CSharp.OrderingRules",
"SA1201:Elements should appear in the correct order",
Justification = "We like private enums at the botom of the file.")]
private enum CredentialProviderExitCode
{
Success = 0,
ProviderNotApplicable = 1,
Failure = 2,
}
private readonly struct AuthenticatedFeed
{
private readonly string accountUrlPattern;
private readonly string providerUrlTemplate;
public AuthenticatedFeed([NotNull] string accountUrlPattern, [NotNull] string providerUrlTemplate)
{
this.accountUrlPattern = accountUrlPattern;
this.providerUrlTemplate = providerUrlTemplate;
}
[CanBeNull]
public string GetAccount([NotNull] string url)
{
var match = Regex.Match(url, accountUrlPattern, RegexOptions.IgnoreCase);
return !match.Success ? null : match.Groups["account"].Value;
}
public string GetProviderUrl([NotNull] string account)
{
return providerUrlTemplate.Replace("{account}", account);
}
}
///
/// Data class returned from nuget credential providers in a JSON format. As described here:
/// https://docs.microsoft.com/en-us/nuget/reference/extensibility/nuget-exe-credential-providers#creating-a-nugetexe-credential-provider.
///
[Serializable]
[SuppressMessage("ReSharper", "UnassignedField.Local", Justification = "Used by serializer.")]
[SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Need to match the serialized name.")]
[SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Justification = "We use public so it can be serialized.")]
#pragma warning disable 0649 // CS0649: Field 'field' is never assigned to, and will always have its default value 'value'
private struct CredentialProviderResponse
{
[CanBeNull]
public string Password;
// Ignore Spelling: Username
[CanBeNull]
public string Username;
}
#pragma warning restore 0649 // CS0649: Field 'field' is never assigned to, and will always have its default value 'value'
}
}