#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.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using JetBrains.Annotations;
using NugetForUnity.Configuration;
using NugetForUnity.Helper;
using NugetForUnity.Models;
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.PackageSource
{
///
/// Represents a NuGet Package Source that uses a remote server with API version v2.
///
[Serializable]
internal sealed class NugetPackageSourceV2 : INugetPackageSource, ISerializationCallbackReceiver
{
///
/// Initializes a new instance of the class.
///
/// The name of the package source.
/// The path to the package source.
/// The explicitly defined protocol version stored inside the 'NuGet.config'.
public NugetPackageSourceV2([NotNull] string name, [NotNull] string url, string savedProtocolVersion)
{
Name = name;
SavedPath = url;
SavedProtocolVersion = savedProtocolVersion;
IsEnabled = true;
}
///
[field: SerializeField]
public string Name { get; set; }
///
public bool SavedPasswordIsEncrypted { get; set; }
///
[field: SerializeField]
public string SavedPath { get; set; }
///
[field: SerializeField]
public string SavedProtocolVersion { get; set; }
///
/// Gets path, with the values of environment variables expanded.
///
[NotNull]
public string ExpandedPath
{
get
{
var path = Environment.ExpandEnvironmentVariables(SavedPath);
return path;
}
}
///
[field: SerializeField]
public string UserName { get; set; }
///
[field: SerializeField]
public string SavedPassword { get; set; }
///
/// Gets password, with the values of environment variables expanded.
///
[CanBeNull]
public string ExpandedPassword
{
get
{
if (SavedPassword == null)
{
return null;
}
var expandedPassword = Environment.ExpandEnvironmentVariables(SavedPassword);
return SavedPasswordIsEncrypted ? ConfigurationEncryptionHelper.DecryptString(expandedPassword) : expandedPassword;
}
}
///
public bool HasPassword
{
get => SavedPassword != null;
set
{
if (value)
{
if (SavedPassword == null)
{
SavedPassword = string.Empty; // Initialize newly-enabled password to empty string.
}
}
else
{
SavedPassword = null; // Clear password to null when disabled.
}
}
}
///
[field: SerializeField]
public bool IsEnabled { get; set; }
///
public void Dispose()
{
// nothing to dispose
}
///
public List FindPackagesById(INugetPackageIdentifier package)
{
List foundPackages;
// See here: http://www.odata.org/documentation/odata-version-2-0/uri-conventions/
var url = $"{ExpandedPath}FindPackagesById()?id='{package.Id}'";
// Are we looking for a specific package?
if (!package.HasVersionRange)
{
url = $"{url}&$filter=Version eq '{package.Version}'";
}
else
{
// As we can't search for a specific Version we need to fetch all versions and remove the non matching versions.
// As packages can have so many versions to find the correct version we fetch up to 1000 instead of the default 100.
url = $"{url}&$top=1000";
}
try
{
foundPackages = GetPackagesFromUrl(url);
}
catch (Exception e)
{
foundPackages = new List();
Debug.LogErrorFormat("Unable to retrieve package list from {0}\n{1}", url, e);
}
// Return all the packages in the range of versions specified by 'package'.
foundPackages.RemoveAll(p => !package.InRange(p));
foundPackages.Sort();
return foundPackages;
}
///
public INugetPackage GetSpecificPackage(INugetPackageIdentifier package)
{
if (package.HasVersionRange)
{
// if multiple match we use the lowest version
return FindPackagesById(package).FirstOrDefault();
}
var url = $"{ExpandedPath}Packages(Id='{package.Id}',Version='{package.Version}')";
try
{
return GetPackagesFromUrl(url).FirstOrDefault();
}
catch (Exception e)
{
Debug.LogErrorFormat("Unable to retrieve package from {0}\n{1}", url, e);
return null;
}
}
///
[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "API uses lower case.")]
public Task> SearchAsync(
string searchTerm = "",
bool includePrerelease = false,
int numberToGet = 15,
int numberToSkip = 0,
CancellationToken cancellationToken = default)
{
// See the functions and parameters defined here: https://www.nuget.org/api/v2/$metadata
// Example URL: "http://www.nuget.org/api/v2/Search()?$filter=IsLatestVersion&$orderby=Id&$skip=0&$top=30&searchTerm='newtonsoft'&targetFramework=''&includePrerelease=false";
var url = ExpandedPath;
// call the search method
url += "Search()?";
// filter results
if (!includePrerelease)
{
url += "$filter=IsLatestVersion&";
}
else
{
url += "$filter=IsAbsoluteLatestVersion&";
}
// order results
url += "$orderby=DownloadCount desc&";
// skip a certain number of entries
url += $"$skip={numberToSkip}&";
// show a certain number of entries
url += $"$top={numberToGet}&";
// apply the search term
url += $"searchTerm='{searchTerm}'&";
// apply the target framework filters
url += "targetFramework=''&";
// should we include prerelease packages?
url += $"includePrerelease={includePrerelease.ToString().ToLowerInvariant()}";
try
{
return Task.FromResult(GetPackagesFromUrl(url));
}
catch (Exception e)
{
Debug.LogErrorFormat("Unable to retrieve package list from {0}\n{1}", url, e);
return Task.FromResult(new List());
}
}
///
[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "API uses lower case.")]
public List GetUpdates(
IEnumerable packages,
bool includePrerelease = false,
string targetFrameworks = "",
string versionConstraints = "")
{
var updates = new List();
var packagesCollection = packages as ICollection ?? packages.ToList();
// check for updates in groups of 10 instead of all of them, since that causes servers to throw errors for queries that are too long
for (var i = 0; i < packagesCollection.Count; i += 10)
{
var packageGroup = packagesCollection.Skip(i).Take(10);
var packageIds = string.Empty;
var versions = string.Empty;
foreach (var package in packageGroup)
{
if (string.IsNullOrEmpty(packageIds))
{
packageIds += package.Id;
}
else
{
packageIds += "|" + package.Id;
}
if (string.IsNullOrEmpty(versions))
{
versions += package.Version;
}
else
{
versions += "|" + package.Version;
}
}
var url =
$"{ExpandedPath}GetUpdates()?packageIds='{packageIds}'&versions='{versions}'&includePrerelease={includePrerelease.ToString().ToLowerInvariant()}&targetFrameworks='{targetFrameworks}'&versionConstraints='{versionConstraints}'&includeAllVersions=true";
try
{
var newPackages = GetPackagesFromUrl(url);
CopyIsManuallyInstalled(newPackages, packagesCollection);
updates.AddRange(newPackages);
}
catch (Exception e)
{
var webResponse = e is WebException webException ? webException.Response as HttpWebResponse : null;
if (webResponse != null && webResponse.StatusCode == HttpStatusCode.NotFound)
{
// Some web services, such as VSTS don't support the GetUpdates API. Attempt to retrieve updates via FindPackagesById.
NugetLogger.LogVerbose("{0} not found. Falling back to FindPackagesById.", url);
return GetUpdatesFallback(packagesCollection, includePrerelease, targetFrameworks, versionConstraints);
}
Debug.LogErrorFormat("Unable to retrieve package list from {0}\n{1}", url, e);
}
}
#if TEST_GET_UPDATES_FALLBACK
// Enable this define in order to test that GetUpdatesFallback is working as intended. This tests that it returns the same set of packages
// that are returned by the GetUpdates API. Since GetUpdates isn't available when using a Visual Studio Team Services feed, the intention
// is that this test would be conducted by using nuget.org's feed where both paths can be compared.
List updatesReplacement = GetUpdatesFallback(installedPackages, includePrerelease, targetFrameworks, versionConstraints);
ComparePackageLists(updates, updatesReplacement, "GetUpdatesFallback doesn't match GetUpdates API");
#endif
if (updates.Count <= 1)
{
return updates;
}
// sort alphabetically, then by version ascending
updates.Sort();
var resultUpdates = new List();
var lastPackage = (NugetPackageV2Base)updates[0];
resultUpdates.Add(lastPackage);
var sb = new StringBuilder();
sb.Append(lastPackage.PackageVersion).Append(": ").Append(lastPackage.ReleaseNotes);
var lastReleaseNotes = lastPackage.ReleaseNotes;
foreach (var nextPackage in updates.Skip(1))
{
if (string.Equals(lastPackage.Id, nextPackage.Id, StringComparison.Ordinal))
{
lastPackage.Versions.Add(nextPackage.PackageVersion);
if (!string.IsNullOrEmpty(nextPackage.ReleaseNotes) &&
!string.Equals(lastReleaseNotes, nextPackage.ReleaseNotes, StringComparison.Ordinal))
{
sb.Insert(0, "\n").Insert(0, nextPackage.ReleaseNotes).Insert(0, ": ").Insert(0, nextPackage.PackageVersion);
}
lastReleaseNotes = nextPackage.ReleaseNotes;
if (lastPackage.PackageVersion < nextPackage.PackageVersion)
{
lastPackage.PackageVersion = nextPackage.PackageVersion;
}
}
else
{
lastPackage.Versions.Reverse();
lastPackage.ReleaseNotes = sb.ToString();
lastPackage = (NugetPackageV2Base)nextPackage;
resultUpdates.Add(lastPackage);
sb.Clear();
sb.Append(lastPackage.PackageVersion).Append(": ").Append(lastPackage.ReleaseNotes);
lastReleaseNotes = lastPackage.ReleaseNotes;
}
}
lastPackage.Versions.Reverse();
lastPackage.ReleaseNotes = sb.ToString();
return resultUpdates;
}
///
public void DownloadNupkgToFile(INugetPackageIdentifier package, string outputFilePath, string downloadUrlHint)
{
if (downloadUrlHint is null)
{
throw new ArgumentNullException(nameof(downloadUrlHint));
}
using (var objStream = WebRequestHelper.RequestUrl(downloadUrlHint, UserName, ExpandedPassword, null))
{
using (var fileStream = File.Create(outputFilePath))
{
objStream.CopyTo(fileStream);
}
}
}
#if TEST_GET_UPDATES_FALLBACK
private static void ComparePackageLists(
List updates,
List updatesReplacement,
string errorMessageToDisplayIfListsDoNotMatch)
{
var matchingComparison = new StringBuilder();
var missingComparison = new StringBuilder();
foreach (var package in updates)
{
if (updatesReplacement.Contains(package))
{
matchingComparison.Append(matchingComparison.Length == 0 ? "Matching: " : ", ");
matchingComparison.Append(package);
}
else
{
missingComparison.Append(missingComparison.Length == 0 ? "Missing: " : ", ");
missingComparison.Append(package);
}
}
var extraComparison = new StringBuilder();
foreach (var package in updatesReplacement)
{
if (!updates.Contains(package))
{
extraComparison.Append(extraComparison.Length == 0 ? "Extra: " : ", ");
extraComparison.Append(package);
}
}
if (missingComparison.Length > 0 || extraComparison.Length > 0)
{
Debug.LogWarningFormat(
"{0}\n{1}\n{2}\n{3}",
errorMessageToDisplayIfListsDoNotMatch,
matchingComparison,
missingComparison,
extraComparison);
}
}
#endif
///
public void OnBeforeSerialize()
{
// do nothing
}
///
public void OnAfterDeserialize()
{
if (string.IsNullOrEmpty(SavedPassword))
{
SavedPassword = null;
}
}
///
/// Builds a list of NugetPackages from the XML returned from the HTTP GET request issued at the given URL.
/// Note that NuGet uses an Atom-feed (XML Syndicaton) superset called OData.
/// See here http://www.odata.org/documentation/odata-version-2-0/uri-conventions/.
///
[NotNull]
[ItemNotNull]
private List GetPackagesFromUrl([NotNull] string url)
{
NugetLogger.LogVerbose("Getting packages from: {0}", url);
var stopwatch = new Stopwatch();
stopwatch.Start();
var packages = new List();
using (var responseStream = WebRequestHelper.RequestUrl(url, UserName, ExpandedPassword, 10000))
{
using (var streamReader = new StreamReader(responseStream))
{
var responsePackages = NugetODataResponse.Parse(XDocument.Load(streamReader), this);
packages.AddRange(responsePackages);
}
}
NugetLogger.LogVerbose("Received {0} packages in {1} ms", packages.Count, stopwatch.ElapsedMilliseconds);
return packages;
}
///
/// Some NuGet feeds such as Visual Studio Team Services do not implement the GetUpdates function.
/// In that case this fallback function can be used to retrieve updates by using the FindPackagesById function.
///
/// The list of currently installed packages.
/// True to include prerelease packages (alpha, beta, etc).
/// The specific frameworks to target?.
/// The version constraints?.
/// A list of all updates available.
[NotNull]
[ItemNotNull]
private List GetUpdatesFallback(
[NotNull] [ItemNotNull] IEnumerable installedPackages,
bool includePrerelease = false,
string targetFrameworks = "",
string versionConstraints = "")
{
var stopwatch = new Stopwatch();
stopwatch.Start();
Debug.Assert(
string.IsNullOrEmpty(targetFrameworks) && string.IsNullOrEmpty(versionConstraints),
"targetFrameworks and versionConstraints is not supported by GetUpdatesFallback");
var updates = new List();
foreach (var installedPackage in installedPackages)
{
var versionRange = $"({installedPackage.Version},)"; // Minimum of Current ID (exclusive) with no maximum (exclusive).
var id = new NugetPackageIdentifier(installedPackage.Id, versionRange);
var packageUpdates = FindPackagesById(id);
if (!includePrerelease)
{
packageUpdates.RemoveAll(p => p.IsPrerelease);
}
if (packageUpdates.Count == 0)
{
continue;
}
var skip = packageUpdates.Count - 1;
updates.AddRange(packageUpdates.Skip(skip));
}
NugetLogger.LogVerbose("NugetPackageSource.GetUpdatesFallback took {0} ms", stopwatch.ElapsedMilliseconds);
return updates;
}
private void CopyIsManuallyInstalled(List newPackages, ICollection packagesToUpdate)
{
foreach (var newPackage in newPackages)
{
newPackage.IsManuallyInstalled =
packagesToUpdate.FirstOrDefault(packageToUpdate => packageToUpdate.Id.Equals(newPackage.Id, StringComparison.OrdinalIgnoreCase))
?.IsManuallyInstalled ??
false;
}
}
}
}